a73x

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,
	},
}