internal/server/api/routes.go
Ref: Size: 16.8 KiB History
package api
import (
"net/http"
"slices"
"github.com/a73x/eitri/internal/server/api/types"
)
// AuthTier is who may call a route.
type AuthTier int
const (
AuthPublic AuthTier = iota // no auth (public material / rate-limited)
AuthTicket // one-time short-TTL ticket in ?ticket= (browser streams)
AuthUser // Authorization: Bearer <PAT>, or eitri_session cookie
)
// RouteKind is what rides the connection after the status line.
type RouteKind int
const (
KindJSON RouteKind = iota
KindSSE
KindWS
)
// QueryParam is a documented query-string parameter (all string-typed).
type QueryParam struct{ Name, Doc string }
// Route is one entry of the server's HTTP surface. The table below IS the
// enumerable contract: Handler registers from it and cmd/eitri-apispec
// projects it into docs/openapi.json.
type Route struct {
Method string
Path string
Auth AuthTier
Kind RouteKind
Request any // typed nil exemplar of the JSON request body; nil = no body
Response any // typed nil exemplar of the success JSON body; nil = no body
Success int // the success status the handler writes
Query []QueryParam
Doc string
handler func(*API, http.ResponseWriter, *http.Request)
}
// Routes returns the surface for tooling (the OpenAPI generator).
func Routes() []Route { return slices.Clone(routeTable) }
var routeTable = []Route{
// Unauthenticated enrollment endpoint.
{
Method: "POST",
Path: "/api/v1/enroll",
Auth: AuthPublic,
Kind: KindJSON,
Request: (*types.EnrollRequest)(nil),
Response: (*types.EnrollResponse)(nil),
Success: http.StatusCreated,
Doc: "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated but rate-limited; the token is the proof.",
handler: (*API).handleEnroll,
},
// Unauthenticated CA-pubkey endpoint: it is public material, and clients need
// it to pin `@cert-authority` for host verification BEFORE they hold any
// credential. 404s when the jump gate is off (no CA published).
{
Method: "GET",
Path: "/api/v1/ssh-ca",
Auth: AuthPublic,
Kind: KindJSON,
Response: (*types.SSHCAResponse)(nil),
Success: http.StatusOK,
Doc: "The eitri SSH host CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off.",
handler: (*API).handleSSHCA,
},
// SSE live status. EventSource cannot set headers, so the stream
// authenticates with a one-time short-TTL ticket minted via the
// user-authenticated POST /api/v1/stream-tickets — the caller's PAT or
// session never rides in a URL (proxy/access logs).
{
Method: "GET",
Path: "/api/v1/events",
Auth: AuthTicket,
Kind: KindSSE,
Response: (*types.StateSnapshot)(nil),
Success: http.StatusOK,
Query: []QueryParam{{Name: "ticket", Doc: "one-time stream ticket"}},
Doc: "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot.",
handler: (*API).handleEvents,
},
// Console WS: ticket-authed like the SSE stream (browsers cannot set
// headers on a WebSocket dial). More specific than the /api/v1/
// user-authenticated subtree, so ServeMux routes it here without that auth.
{
Method: "GET",
Path: "/api/v1/vms/{id}/console/ws",
Auth: AuthTicket,
Kind: KindWS,
Success: http.StatusSwitchingProtocols,
Query: []QueryParam{{Name: "ticket", Doc: "one-time stream ticket"}},
Doc: "Serial-console WebSocket: raw byte pipe to the VM's serial console.",
handler: (*API).handleConsoleWS,
},
// User routes — wrapped with the PAT/session auth middleware.
{
Method: "POST",
Path: "/api/v1/enroll-tokens",
Auth: AuthUser,
Kind: KindJSON,
Response: (*types.EnrollTokenResponse)(nil),
Success: http.StatusCreated,
Doc: "Mint a one-time host enrollment token plus the join blob agents consume.",
handler: (*API).handleCreateEnrollToken,
},
{
Method: "GET",
Path: "/api/v1/hosts",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.Host(nil),
Success: http.StatusOK,
Doc: "List fleet hosts: durable rows merged with live agent state and allocation.",
handler: (*API).handleListHosts,
},
{
Method: "DELETE",
Path: "/api/v1/hosts/{id}",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusAccepted,
Query: []QueryParam{{Name: "force", Doc: "purge VM rows and remove the host immediately (dead hardware escape hatch)"}},
Doc: "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200.",
handler: (*API).handleDecommissionHost,
},
{
Method: "POST",
Path: "/api/v1/hosts/{id}/revoke-credential",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusNoContent,
Doc: "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled.",
handler: (*API).handleRevokeCredential,
},
{
Method: "POST",
Path: "/api/v1/hosts/{id}/upgrade-agent",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusAccepted,
Doc: "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout).",
handler: (*API).handleUpgradeAgent,
},
{
Method: "GET",
Path: "/api/v1/audit",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.AuditEvent(nil),
Success: http.StatusOK,
Query: []QueryParam{{Name: "limit", Doc: "max rows to return (default 100, cap 1000)"}},
Doc: "Newest audit log rows.",
handler: (*API).handleListAudit,
},
{
Method: "POST",
Path: "/api/v1/stream-tickets",
Auth: AuthUser,
Kind: KindJSON,
Response: (*types.StreamTicketResponse)(nil),
Success: http.StatusCreated,
Doc: "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL.",
handler: (*API).handleMintStreamTicket,
},
{
Method: "GET",
Path: "/api/v1/vms",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.VM(nil),
Success: http.StatusOK,
Doc: "List VMs: durable rows merged with live agent-reported actual state.",
handler: (*API).handleListVMs,
},
{
Method: "POST",
Path: "/api/v1/vms",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.CreateVMRequest)(nil),
Response: (*types.CreateVMResponse)(nil),
Success: http.StatusCreated,
Doc: "Create a VM on a host. Omitted fields get one-click defaults; the tenant must have a registered SSH user CA first.",
handler: (*API).handleCreateVM,
},
{
Method: "PATCH",
Path: "/api/v1/vms/{id}",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.PatchVMRequest)(nil),
Success: http.StatusNoContent,
Doc: "Set a VM's desired power state (running or stopped).",
handler: (*API).handlePatchVM,
},
{
Method: "DELETE",
Path: "/api/v1/vms/{id}",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusNoContent,
Doc: "Tombstone a VM for teardown; restorable within the grace window via restore.",
handler: (*API).handleDeleteVM,
},
{
Method: "POST",
Path: "/api/v1/vms/{id}/restore",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusNoContent,
Doc: "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest.",
handler: (*API).handleRestoreVM,
},
{
Method: "GET",
Path: "/api/v1/vms/{id}/events",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.AuditEvent(nil),
Success: http.StatusOK,
Query: []QueryParam{{Name: "limit", Doc: "max rows to return (default 100, cap 1000)"}},
Doc: "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped.",
handler: (*API).handleListVMEvents,
},
// Service exposure: a tenant publishes one port of one VM, TCP or UDP, and
// the fleet binds it on that VM's host. There is no update verb — an
// exposure is a grant, not a document.
{
Method: "POST",
Path: "/api/v1/vms/{id}/exposures",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.CreateExposureRequest)(nil),
Response: (*types.Exposure)(nil),
Success: http.StatusCreated,
Doc: "Publish a guest port on the VM's host, protocol \"tcp\" (the default) or \"udp\". Omit host_port to allocate one from the reserved range 30000-32767; a named port must be >= 1024 and is honored or refused, and is taken only by another exposure of the same protocol.",
handler: (*API).handleCreateExposure,
},
{
Method: "GET",
Path: "/api/v1/vms/{id}/exposures",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.Exposure(nil),
Success: http.StatusOK,
Doc: "List the VM's published ports, with the host address to dial and each listener's reported state.",
handler: (*API).handleListExposures,
},
{
Method: "DELETE",
Path: "/api/v1/exposures/{id}",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusNoContent,
Doc: "Revoke an exposure; its host closes the listener on the next converge.",
handler: (*API).handleDeleteExposure,
},
// Volume claims: durable raw block storage a tenant claims and attaches to
// a VM at create; the bytes outlive the VM. There is no update verb — size
// is immutable once bound, because a guest filesystem sits on it.
{
Method: "POST", Path: "/api/v1/volume-claims", Auth: AuthUser, Kind: KindJSON,
Request: (*types.CreateVolumeClaimRequest)(nil), Response: (*types.VolumeClaim)(nil), Success: http.StatusCreated,
Doc: "Claim durable storage. Pending until the first VM naming it is created; that VM's host then holds the bytes, and every later VM using the claim is placed there.",
handler: (*API).handleCreateVolumeClaim,
},
{
Method: "GET", Path: "/api/v1/volume-claims", Auth: AuthUser, Kind: KindJSON,
Response: []types.VolumeClaim(nil), Success: http.StatusOK,
Doc: "List the tenant's claims: where each is bound, which VM holds it, and whether its host has the file.",
handler: (*API).handleListVolumeClaims,
},
{
Method: "GET", Path: "/api/v1/volume-claims/{id}", Auth: AuthUser, Kind: KindJSON,
Response: (*types.VolumeClaim)(nil), Success: http.StatusOK,
Doc: "One claim.",
handler: (*API).handleGetVolumeClaim,
},
{
Method: "DELETE", Path: "/api/v1/volume-claims/{id}", Auth: AuthUser, Kind: KindJSON, Success: http.StatusNoContent,
Doc: "Delete a claim and the data behind it. Refused (409) while a VM holds it.",
handler: (*API).handleDeleteVolumeClaim,
},
// BYO per-tenant SSH user CAs: eitri stores only the CA pubkey and never
// holds a user signing key. Tenant-scoped (caller must act for {tenant}).
{
Method: "POST",
Path: "/api/v1/tenants/{tenant}/user-cas",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.UserCARequest)(nil),
Response: (*types.UserCAUploadResponse)(nil),
Success: http.StatusCreated,
Doc: "Register a BYO SSH user CA public key for the tenant; eitri never holds a user signing key.",
handler: (*API).handleUploadUserCA,
},
{
Method: "GET",
Path: "/api/v1/tenants/{tenant}/user-cas",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.UserCA(nil),
Success: http.StatusOK,
Doc: "List the tenant's registered SSH user CAs (pubkey, label, fingerprint).",
handler: (*API).handleListUserCAs,
},
{
Method: "DELETE",
Path: "/api/v1/tenants/{tenant}/user-cas",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.UserCARequest)(nil),
Success: http.StatusNoContent,
Doc: "Remove a registered SSH user CA by its public_key line.",
handler: (*API).handleDeleteUserCA,
},
// Tenant-less siblings that operate on the CALLER'S OWN tenant: the PAT or
// session already names the tenant, so no {tenant} rides in the path.
{
Method: "POST",
Path: "/api/v1/user-cas",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.UserCARequest)(nil),
Response: (*types.UserCAUploadResponse)(nil),
Success: http.StatusCreated,
Doc: "Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key.",
handler: (*API).handleUploadUserCA,
},
{
Method: "GET",
Path: "/api/v1/user-cas",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.UserCA(nil),
Success: http.StatusOK,
Doc: "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint).",
handler: (*API).handleListUserCAs,
},
// Delegation: eitri holds an ephemeral keypair per tenant, in memory only.
// The caller signs its public half with a CA they have already registered
// and posts the certificate back, and eitri authenticates to that tenant's
// guests with it until it expires. eitri never holds a signing key, and a
// restart drops every delegation. Always the caller's own tenant.
{
Method: "POST",
Path: "/api/v1/delegations",
Auth: AuthUser,
Kind: KindJSON,
Response: (*types.DelegationChallenge)(nil),
Success: http.StatusOK,
Doc: "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process once delegated; a begin left unsigned for an hour is abandoned and the next call returns a new key.",
handler: (*API).handleBeginDelegation,
},
{
Method: "PUT",
Path: "/api/v1/delegations",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.DelegationRequest)(nil),
Response: (*types.Delegation)(nil),
Success: http.StatusOK,
Doc: "Complete a delegation with the certificate your CA signed. The certificate must be a user certificate over the key this delegation issued, signed by a CA registered to your tenant, naming the guest login user as a principal.",
handler: (*API).handleCompleteDelegation,
},
{
Method: "GET",
Path: "/api/v1/delegations",
Auth: AuthUser,
Kind: KindJSON,
Response: (*types.Delegation)(nil),
Success: http.StatusOK,
Doc: "Describe the caller tenant's live delegation, including when it expires. 404 when there is none.",
handler: (*API).handleGetDelegation,
},
{
Method: "DELETE",
Path: "/api/v1/delegations",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusNoContent,
Doc: "End the caller tenant's delegation now. eitri drops the certificate and can no longer reach that tenant's VMs.",
handler: (*API).handleRevokeDelegation,
},
// Revoke a minted user cert (by serial or cert line) and list revocations —
// enforced at the gate before a cert's short TTL expires. Pure store ops,
// available regardless of whether the minter is wired.
{
Method: "POST",
Path: "/api/v1/ssh-certs/revoke",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.RevokeSSHCertRequest)(nil),
Success: http.StatusNoContent,
Doc: "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent.",
handler: (*API).handleRevokeSSHCert,
},
{
Method: "GET",
Path: "/api/v1/ssh-certs/revoked",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.RevokedCert(nil),
Success: http.StatusOK,
Doc: "List revoked SSH user certificate serials (with reason and time), newest first.",
handler: (*API).handleListRevokedSSHCerts,
},
// Identity + personal access token lifecycle. /me renders the signed-in
// identity; PATs are the non-browser API credential (minted with a session,
// value shown once, listed as metadata only, revoked by id).
{
Method: "GET",
Path: "/api/v1/me",
Auth: AuthUser,
Kind: KindJSON,
Response: (*types.Me)(nil),
Success: http.StatusOK,
Doc: "The signed-in identity: the caller's tenant handle, bound email, and the plane's SSH jump-gate address (empty when it runs no gate).",
handler: (*API).handleMe,
},
{
Method: "POST",
Path: "/api/v1/tokens",
Auth: AuthUser,
Kind: KindJSON,
Request: (*types.CreateAPITokenRequest)(nil),
Response: (*types.CreateAPITokenResponse)(nil),
Success: http.StatusCreated,
Doc: "Mint a personal access token; the secret is returned exactly once. An optional TTL sets expiry (0 = non-expiring).",
handler: (*API).handleCreateAPIToken,
},
{
Method: "GET",
Path: "/api/v1/tokens",
Auth: AuthUser,
Kind: KindJSON,
Response: []types.APIToken(nil),
Success: http.StatusOK,
Doc: "List the tenant's personal access tokens (metadata only — never the secret), newest first.",
handler: (*API).handleListAPITokens,
},
{
Method: "DELETE",
Path: "/api/v1/tokens/{id}",
Auth: AuthUser,
Kind: KindJSON,
Success: http.StatusNoContent,
Doc: "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak).",
handler: (*API).handleRevokeAPIToken,
},
}