Docs / HTTP API

KLYRN VM controller HTTP API (v1)

Base: https://<controller>:8443/api/v1. JSON in, JSON out. Errors are {"error":{"code","message","hint?","details?"}} with the HTTP status implied by the code (invalid 400, unauthenticated 401, forbidden 403, not_found 404, conflict 409, not_ready 409, capacity 409, rate_limited 429, unavailable 503, internal 500).

Authentication: a session cookie (klyrnvm_session, SameSite=Strict) or Authorization: Bearer klyrnvm_…. State-changing requests from a browser must carry X-Klyrn-Request: 1 (CSRF), and Origin must match.

Lists take ?page=&page_size=&sort=&q= plus documented filters and return api.Page[T]: {items, total, page, page_size, pages}. Page size defaults to 50, max 200.

Setup and auth

MethodPathBody → Result
GET/setup/status→ SetupStatus
POST/setupSetupRequest → LoginResult (creates the first admin; the token was printed by the installer)
POST/auth/loginLoginRequest → LoginResult
POST/auth/logout
GET/me→ Me

| GET | /overview | → Overview | | GET | /search?q= | → []SearchResult (server-side, capped at 20) | | GET | /events | server-sent events: node, vm, task, task_log, image, alert, activity, system |

Nodes (admin)

MethodPathNotes
GET/nodesfilters: state, mode, location → Page[Node] (facts omitted in lists)
POST/nodes/bootstrapNodeBootstrapRequest → NodeBootstrap (token shown once)
GET/nodes/bootstrappending tokens → []NodeBootstrap (no token)
DELETE/nodes/bootstrap/{id}
GET/nodes/{id}→ Node with facts, capacity, health
PATCH/nodes/{id}NodeUpdateRequest (name, mode observe→managed, maintenance, overcommit)
DELETE/nodes/{id}revokes the certificate; refused while VMs are placed here
GET/nodes/{id}/domainsobserved libvirt domains → []ObservedDomain
GET/nodes/{id}/imagesthe node's cache → []NodeImage
POST/nodes/{id}/inventoryask for a fresh inventory → Task
POST/nodes/{id}/preflightre-run readiness → Task
GET/nodes/{id}/tasks→ Page[Task]
GET/nodes/{id}/samples?hours=hourly capacity points

The bootstrap command served for a token: GET /get-node.sh (public, unauthenticated, the script only; the token travels in the env of the command the UI shows).

Virtual machines

MethodPathNotes
GET/vmsfilters: status, node, project, q → Page[VM]
POST/vmsVMCreateRequest → VMCreateResult (201; 200 with replayed on an idempotency hit)
GET/vms/{id}→ VM
PATCH/vms/{id}VMUpdateRequest
DELETE/vms/{id}VMDeleteRequest (confirm = name; destroy_disks separate) → Task
POST/vms/{id}/start→ Task
POST/vms/{id}/shutdowngraceful, 90 s deadline → Task
POST/vms/{id}/reboot→ Task
POST/vms/{id}/force-stopVMPowerRequest{confirm} → Task
POST/vms/{id}/resetVMPowerRequest{confirm} → Task
GET/vms/{id}/tasks→ Page[Task]
GET/vms/{id}/activity→ Page[AuditEntry]
GET/vms/{id}/metrics?res=1m&hours=24→ MetricSeries (from the node; 503 unavailable when offline)
POST/vms/{id}/console`{kind?: vncserial} → ConsoleTicket`
WS/console/ws/{ticket}binary RFB frames (noVNC) or bytes (serial)
POST/vms/{id}/reset-password{password?} → {user, task_id, chosen?, password?}: the chosen password, or a generated one returned once, set through the guest agent
GET/vms/{id}/windows→ WindowsSetup: a Windows machine's first boot and whether its chosen password was set
POST/vms/{id}/windows/credential→ the password a Windows machine set for itself, once
GET/vms/{id}/migration-targetsadmin → []MigrationTarget: every other node judged, with mode live, restart or offline
POST/vms/{id}/migrateadmin {node_id, idempotency_key} → Task (vm.migrate)
POST/vms/{id}/cpuadmin {cpu_mode, restart?, idempotency_key} → Task (vm.cpu.set): rewrites only the <cpu> element of the stored definition. A running machine keeps its processor until it is stopped and started; restart shuts it down cleanly and starts it again, and a guest that has not shut down within three minutes is left running, unchanged. The facts' cpu.next_mode says a change is waiting

Customers see only VMs in their projects. Admins see all and get placement on create.

Passwords. VMCreateRequest.set_password (and the same field on a reinstall) chooses the password for the account the access mode names: root with root_password, the image's own user with user_sudo, and the administrator on Windows, where access_mode is left out. It must pass CheckPassword: 12 to 128 characters, three of lowercase, uppercase, digits and symbols, printable ASCII without spaces, and not containing the account's name. It is never echoed (password_chosen: true says it was taken) and never stored. A Linux guest receives its SHA-512 crypt only. A Windows guest receives it through its own agent once its first boot has reported, and GET /vms/{id}/windows says when (password_set_at) or why not (password_error, with the machine's own password offered once in its place). password: true without set_password still asks for a generated one, returned once.

Tasks

| GET | /tasks | filters: status, op, node, resource_kind → Page[Task] | | GET | /tasks/{id} | → Task | | GET | /tasks/{id}/events?after= | → []TaskEvent | | POST | /tasks/{id}/cancel | |

Images (admin writes, everyone reads enabled ones)

| GET | /images | → []Image | | POST | /images | ImageCreateRequest (custom) | | POST | /images/refresh | read publisher checksums → Task | | PATCH | /images/{id} | {enabled} | | POST | /images/{id}/pull | {node_id} → Task (prefetch on a node) |

Networks and IPAM (admin)

| GET | /networks | → []Network with pools | | POST | /networks | NetworkCreateRequest | | PATCH | /networks/{id} | {default, mtu} | | DELETE | /networks/{id} | refused while allocated | | POST | /networks/{id}/pools | IPPoolCreateRequest | | GET | /pools/{id}/allocations | → Page[IPAllocation] |

Projects, SSH keys, plans, locations, users, tokens

| GET/POST | /projects | | | GET/POST | /ssh-keys, DELETE /ssh-keys/{id} | a user's own; admins see all | | GET/POST/PATCH/DELETE | /plans | | | GET/POST | /locations | | | GET/POST/PATCH | /users | admin | | GET/POST/DELETE | /tokens | APITokenCreateRequest → APITokenCreated once | | GET | /activity | → Page[AuditEntry] (admin: all; customer: own) | | GET | /alerts | open alerts; POST /alerts/{id}/ack | | GET | /settings, PATCH | admin |

Administering people, and support sessions

Everything under /admin/users is administrator only. It is a separate prefix from /users on purpose: /users answers "who can I put on a project" for every page that needs a name, and widening that answer would send a customer's phone number and suspension reason to a drop-down.

| GET | /admin/users | ?q=&role=&status=active|suspended -> Page[Person] | | GET | /admin/users/{id} | one person, with what they own | | PATCH | /admin/users/{id} | name, email, role, phone, company, note. Omitted fields are unchanged | | POST | /admin/users/{id}/suspend | {reason}. Required. The person is shown this sentence when they sign in | | POST | /admin/users/{id}/restore | lifts it and clears the reason | | POST | /admin/users/{id}/sign-out | ends every browser session. API tokens are untouched | | POST | /admin/users/{id}/password | {password}. Never logged, never audited, ends every session | | POST | /admin/users/{id}/impersonate | {reason} -> SupportSession, and the cookie becomes the customer's | | GET | /admin/users/{id}/impersonations | who has been inside this account | | POST | /impersonation/end | any session. Returns the operator to their own |

GET /me carries impersonation when the cookie is a support session, so the UI draws its band from the request every page already makes.

A support session:

  • only an administrator may start one, never from an API token, never against an administrator or a member of staff, never against themselves, and never from inside another one.
  • lasts 30 minutes and is enforced on the request that discovers the expiry, not only by a sweep.
  • is read-mostly. Every GET is allowed. The only three requests that are not reads and are still allowed are POST /auth/logout, POST /impersonation/end and POST /vms/{id}/start. Everything else is refused with forbidden and the message "a support session can look, not act": no delete of anything, no shutdown, reboot, force stop or reset, no console, no create, reinstall, resize or rescue, no token creation or revocation, no SSH key change, no password change, no security group change, nothing under /provision, no sign-in and no SSO redeem.
  • writes every audit row naming both parties. actor reads operator@example.com as customer@example.com, via is impersonation, impersonation_id joins to the row holding both user ids, and actor_id is the CUSTOMER, so it appears in their own /activity.

Production networking and IPAM

Everything here is admin-only except the machine-scoped calls at the end, which a customer may make for their own machines.

Pools

MethodPathWhat it does
GET/api/v1/poolsevery pool, with counts by state and pressure
GET/api/v1/pools/{id}one pool
PATCH/api/v1/pools/{id}change name, gateway, range, DNS, topology, gateway mode, guest prefix, location, nodes, VLAN, enabled. The CIDR and family never change: that would be a different pool. An edit that would put an address a guest is using outside the range is refused and names the address.
DELETE/api/v1/pools/{id}refused while anything is held, including RELEASING
POST/api/v1/networks/{id}/poolsadd a pool to a network

A pool carries topology (nat \| bridged \| routed), gateway_mode (onlink \| subnet), guest_prefix_len (what the GUEST is configured with, 32 for a routed IPv4), node_ids (empty means every node in the location), reserved_count, allocated_count, releasing_count, blocked_count and pressure.

An IPv6 pool is a prefix and prefix_len is the size of the sub-prefix each machine receives (default 64). total is the number of sub-prefixes, never the number of addresses.

Addresses

MethodPathWhat it does
GET/api/v1/pools/{id}/allocationsevery row in the pool: allocations and reservations together, because "what is in this pool" is one question
GET/api/v1/pools/{id}/reservationswhat is set aside, and why
POST/api/v1/pools/{id}/reservations{ip, range_end?, kind?, note}, the note is required
DELETE/api/v1/pools/{id}/reservations/{rid}release a reservation; one KLYRN made from the pool's own arithmetic is refused
POST/api/v1/pools/{id}/block{ip, note}: take an address out of service; refused while a machine holds it
POST/api/v1/pools/{id}/unblock{ip}

An allocation's state is allocated, releasing, reserved or blocked. FREE is the absence of a row. releasing means the machine has gone and the compute node has not yet confirmed the host stopped routing and filtering the address; it will not be handed out until it does.

Security groups

MethodPathWhat it does
GET/api/v1/security-groupsthe caller's groups (an admin sees all)
POST/api/v1/security-groups{name, description?, project_id?, default_ingress?, default_egress?}, defaults are drop in, accept out
GET/api/v1/security-groups/{id}one group with its rules
DELETE/api/v1/security-groups/{id}refused while a machine uses it
POST/api/v1/security-groups/{id}/rules{direction, action?, protocol, family?, port_from?, port_to?, cidr?, icmp_type?, note?}; every machine using the group is re-applied
DELETE/api/v1/security-groups/{id}/rules/{rid}same

A rule can only describe the guest's own traffic. There is no field for an interface, a chain or the host's address.

One machine's networking

MethodPathWhat it does
GET/api/v1/vms/{id}/networkingNICs, every address with its state, the groups attached, this month's transfer, and how many IPv4 the plan allows
GET/api/v1/vms/{id}/bandwidththe last twelve months, newest first
POST/api/v1/vms/{id}/ips{family?, pool_id?, ip?, nic_id?}: attach another address. Bounded by the plan's ipv4_count for a customer. The anti-spoof rules are rewritten in the same operation.
POST/api/v1/vms/{id}/ips/detach{ip}: the address goes to RELEASING at once and becomes free when the node confirms. The primary address cannot be detached.
PUT/api/v1/vms/{id}/security-groups{group_ids}: replaces the whole set and applies it
PATCH/api/v1/vms/{id}/network{rate_mbps}: 0 removes the cap (admin)

The traffic allowance

MethodPathWhat it does
GET/api/v1/traffic?period=YYYY-MMevery machine the caller may see for one period, with the allowance beside the usage, the periods that exist, and the last enforcement decisions. Scoped in SQL: a customer sees their own machines and their own totals
GET/api/v1/vms/{id}/traffic?period=YYYY-MMone machine: its position, its whole enforcement history, and twelve months of transfer
PUT/api/v1/plans/{id}/traffic{traffic_gib, traffic_direction, traffic_action, traffic_throttle_mbps} (admin + admin scope)
GET/api/v1/backups/scheduleevery scheduled machine the caller may see, with when it last completed, how many copies it has and when the next run is due. Scoped in SQL, like traffic
PUT/api/v1/plans/{id}/backups{backup_schedule, backup_hour, backup_weekday, backup_keep} (admin)

traffic_direction is outbound (the guest's uploads, the default) or both. traffic_action is report, throttle or suspend. backup_schedule is off, daily or weekly. backup_hour is UTC and backup_weekday is 0 for Sunday. backup_keep is how many COMPLETE backups to keep, and 0 keeps every one of them: the difference between keeping all and deleting all must not be one mistyped digit. A retention of exactly 1 is refused, because while the next backup is being written the copy it replaces would be the only one there is.

Saving a policy deletes nothing. The response carries deletes, the number of backups the new retention WOULD remove, and the removal happens later, after each machine's next backup has completed, so a machine never has fewer good copies than it had a moment ago.

The response to PUT is not just the plan. It carries effective_from, machines, deferred and a written note, because a tightening starts next period. An allowance added to a plan that already has machines on it cannot put them in breach of a limit that did not exist yesterday. Raising or removing an allowance applies at once.

Every usage figure carries resets, samples and first_at. They are the reasons the number can read LOW: a guest restart loses the traffic between the last reading and the restart, and a reporting gap loses the gap. Nothing here can make it read high. Present it as a floor, not as an exact count.

Node operations these use

vm.net.apply carries the addresses, topology, firewall and rate limit for one machine and makes the host agree with all four together. vm.net.release removes host routes and rules and names back exactly what it removed; the controller frees only those. node.net.probe reads what the host really has, so a difference can be shown rather than assumed.

When a host can no longer be asked

POST /api/v1/pools/{id}/force-free with {ip, note} returns a RELEASING address to the pool on an operator's word. It exists because the alternative is worse: an address whose host is gone, rebuilt, or no longer identifiable would otherwise sit out of the pool for ever with no way back. It is admin-only, it is audited, and the note is required: it is the only record of why bypassing the node's confirmation was safe.

The provisioning API (what a billing system drives)

Base /api/v1/provision. Every endpoint needs the provision scope (or admin). This surface adds no way to make a virtual machine: it reaches the same CreateVM the customer panel does. There is one implementation of provisioning and this is not a second one.

Idempotency, which is the point

A billing system RETRIES. POST /provision/services requires idempotency_key. The key is namespaced to the calling token, fingerprinted against the request body, and claimed inside a transaction before any work starts, so twenty concurrent retries of one order produce one machine and twenty identical answers.

  • the same key with a different body → 409 conflict, nothing happens
  • the same key while the first call is still running → the caller waits for it and then gets the first answer
  • the same key after a refusal → the order is tried again; a key guards against a duplicate success, not against a retry after a refusal
  • (integration, external_ref) is unique as a second, independent guard, so a CreateAccount clicked twice with two different keys still cannot make two machines

Services

MethodPathBody → Result
GET/provision/ping→ what this KLYRN is and whether it can provision at all
GET/provision/catalogue→ ProvisionCatalogue (plans, images, locations, suspend policy) in one call
POST/provision/servicesProvisionCreateRequest → ProvisionResult (201, or 200 with replayed)

ProvisionCreateRequest.access_mode is optional and opt in. Left out, the customer gets the image's own login user with sudo (ubuntu on Ubuntu), which is what this path has always produced, so an integration whose welcome email says ssh ubuntu@ is never silently changed. Set it to root_password or root_key to offer root instead. An unknown value is refused rather than quietly defaulted. | GET | /provision/services | filters: state, integration, external_ref → Page[Service] | | GET | /provision/by-ref/{ref}?integration= | the billing system's own id → Service | | GET | /provision/services/{id} | → Service | | GET | /provision/services/{id}/events | → []ServiceEvent, newest first | | GET | /provision/services/{id}/usage | ?period=YYYY-MM → ServiceUsage | | POST | /provision/services/{id}/suspend | {mode?, reason?}, never touches a disk | | POST | /provision/services/{id}/unsuspend | reverses what the suspension actually did | | POST | /provision/services/{id}/terminate | {confirm: true, destroy_disks?}, the only destructive call | | POST | /provision/services/{id}/change-plan | {plan_id\|plan_slug, reboot?}, resizes the SAME machine | | POST | /provision/services/{id}/resize | explicit sizes instead of a plan | | POST | /provision/services/{id}/reinstall | same machine, same address, new image | | POST | /provision/services/{id}/retry | recovers a provisioning_failed service | | POST | /provision/services/{id}/sso | → {url, expires_at}, single use, 90 seconds | | GET | /provision/tasks/{id} | → {task, events}, provisioning status, polled rather than guessed |

ServiceState is provisioning → active | provisioning_failed, plus suspended and terminated. **provisioning_failed is the state that has to exist**: the money was taken and the machine was not made, and that is neither "unpaid" (the customer is billed and blamed) nor "active" (the customer is billed for nothing and finds out alone). A failed provisioning releases its storage promise, leaves no orphan address, never reads as a running machine, and has retry as the way out, which reuses the same service row, so the billing system's handle never changes.

Suspension

PUT /provision/settings with {suspend_mode}, provider-wide:

  • power_off: ACPI shutdown. Disks, addresses and configuration untouched.
  • network_isolate: the guest keeps running; the host stops carrying its traffic. Implemented as a drop-all firewall in the ordinary vm.net.apply, recorded on the SERVICE row rather than as a security group, because a customer may edit their groups and must never be able to edit their way out of a suspension.

The mode that was used is stored, so unsuspend reverses what happened rather than what the setting says today.

Adoption: linking a service to a machine KLYRN already runs

The dangerous hour of a migration is the one where the billing system knows how to CREATE and the estate is already running.

MethodPathNotes
POST/provision/adopt/previewAdoptPreviewRequest → AdoptPreview. Writes nothing.
POST/provision/adoptthe same body plus confirm (the machine's UUID)
POST/provision/linkadmin only: link to a machine with no staged record, confirm is the machine's name

The preview reads three things (the billing system's id, the Migration Center's staging row, and KLYRN's own vms table), and returns one line:

WHMCS service 4128 → Solus server 188 → KLYRN VM 12 legacy-01   MATCH

Verdicts: match, staged_only, already_linked, taken, no_uuid, not_found. Only match may be adopted. would_create_vm and would_change_ip are false on every path that reaches match, and adopt contains no call to CreateVM at all.

The handoff

GET /sso/{token} is public by necessity: the customer is not signed in yet. It is single use, expires in ninety seconds, and only redirects to a path inside this panel.

This page is generated from the guide that ships in the product's repository, so it describes the release it was built from. What changed in each release.