Documentation

HTTP API

Every endpoint Kino Cloud exposes - who may call it, what it takes, and what it returns.

Base URL: https://kino.dpdns.org. Everything is JSON except the install script and the health check. All timestamps are integer unix seconds.

Authentication#

Pass one of these:

Header Who uses it
Authorization: Bearer <session JWT> API clients, after /api/auth/signin
Authorization: Bearer kck_… Kino SSH Manager, with an account key
Authorization: Bearer kca_… An agent, refreshing its token
Authorization: Bearer <relay JWT> An agent or manager, on the relay-facing routes
session cookie The web UI only; not part of this API

Errors come back as {"detail": "..."} with a matching status code: 401 for a missing or bad credential, 403 for a valid credential that is not allowed, 404 for something that does not exist or is not yours, 409 for a conflict, 422 for a validation failure.

There is no interactive docs page

The OpenAPI UI is disabled. This page is the reference.

Accounts#

POST /api/auth/signup#

Create an account. Returns 403 if registration is closed on this server, 409 if the email is taken.

{ "email": "[email protected]", "password": "at least 8 chars" }
{ "session_token": "eyJ…", "email": "[email protected]" }

POST /api/auth/signin#

Same body, same response. 401 on a wrong email or password — deliberately indistinguishable from each other, in content and in timing.

GET /api/auth/me#

Auth: session. Returns { "id": 1, "email": "[email protected]" }.

Machines#

Auth for all of these: session or account key.

POST /api/machines#

{ "name": "homelab-pi" }
{
  "agent_id": "6b1d…",
  "name": "homelab-pi",
  "created_at": 1785000000,
  "relay_url": null,
  "last_seen": null,
  "enroll_key": "kce_…",
  "install_command": "curl -fsSL …/install/kce_… | sudo sh"
}

enroll_key is shown exactly once. 403 if you are at the machine limit.

GET /api/machines#

Your machines, oldest first, each with relay_url and last_seen filled in from presence if the agent has reported.

DELETE /api/machines/{agent_id}#

Soft-deletes the machine and drops its presence row. Returns {"ok": true}, or 404 if it is not yours. This is what revokes the agent key — see Revocation.

POST /api/machines/{agent_id}/connect#

Everything the manager needs to dial one machine, in one call.

{
  "token": "eyJ…",
  "expires_at": 1785003600,
  "relay_url": "wss://relay.example.com",
  "relay_healthy": true
}

The token is a 1-hour manager JWT scoped to this agent_id. relay_url is null if the agent has never reported presence.

Account keys#

Auth: session only. An account key cannot mint account keys — see why.

POST /api/account-keys#

{ "name": "laptop" }

Returns the key record plus "key": "kck_…", the only time the full secret exists outside the caller.

GET /api/account-keys#

All your keys, newest first, with prefix, created_at, last_used_at and revoked_at. Never the key itself.

POST /api/account-keys/{key_id}/revoke#

{"ok": true}, or 404 if there is no such active key.

Agent lifecycle#

POST /api/agents/exchange#

No auth — the enroll key is the credential. One-time.

{ "enroll_key": "kce_…" }
{
  "agent_id": "6b1d…",
  "name": "homelab-pi",
  "agent_key": "kca_…",
  "token": "eyJ…",
  "expires_at": 1785086400
}

401 if the key is unknown or already used. The exchange is transactional, so two racing installers cannot both win.

POST /api/agents/refresh#

Auth: kca_… agent key. No body.

{ "agent_id": "6b1d…", "token": "eyJ…", "expires_at": 1785086400 }

401 once the machine is deleted — this is the churn cutoff.

POST /api/agents/presence#

Auth: agent relay JWT.

{ "relay_url": "wss://relay.example.com", "agent_id": "optional" }

The token decides which agent_id the presence belongs to; agent_id in the body is only consulted when the token is unscoped ("*"), and is required in that case. A client cannot claim someone else’s id with a scoped token.

GET /api/agents/{agent_id}/relay#

Auth: session, or a manager JWT scoped to this agent.

{
  "agent_id": "6b1d…",
  "relay_url": "wss://relay.example.com",
  "updated_at": 1785000000,
  "relay_healthy": true
}

404 if no presence has been reported yet; 403 if the token is scoped to a different agent.

Relays#

POST /api/enroll-codes#

Auth: session. Returns { "code": "…" } — a one-time relay enrollment code.

POST /api/relays/enroll#

No auth — the code is the credential. Called by the relay itself.

{ "code": "…", "url": "wss://relay.example.com", "name": "eu-1" }
{ "public_key": "-----BEGIN PUBLIC KEY-----\n…" }

The controller probes <url>/healthz first and returns 400 if it cannot reach it, so the relay must already be serving publicly. 401 if the code is unknown or spent. Re-enrolling an existing URL updates the row rather than duplicating it.

GET /api/relays#

Auth: session, agent JWT, or manager JWT — any of the three.

[
  { "url": "wss://relay.example.com", "name": "eu-1",
    "healthy": true, "last_healthy": 1785000000 }
]

Ordered best-first: assignment policy lives here, and agents latency-race the top of the list. The current policy is simply healthy-first; region, tier, and load slot in later without any client change.

Relay tokens#

POST /api/tokens#

Auth: session.

{ "role": "agent", "agent_id": "6b1d…", "ttl_hours": 720 }
{ "token": "eyJ…", "role": "agent", "agent_id": "6b1d…", "expires_at": 1787592000 }

role is agent or manager. agent_id defaults to "*" (any agent); ttl_hours defaults to 720 and is capped at 8760. Prefer the short-lived refresh flow — see the note on long-lived tokens.

GET /api/tokens#

Auth: session. Your last 200 mints, newest first. Metadata only — the signed tokens are never stored.

Infrastructure#

GET /healthz#

ok as text/plain. No auth.

GET /api/public-key#

The controller’s Ed25519 public key, PEM, as text/plain. No auth — it is public by definition, and it is what a relay verifies with.

GET /install/{enroll_key}#

A shell script bound to one machine, as text/x-shellscript. Fetching does not consume the key; running it does. Returns a script that exits with an error message (status 404) if the link is invalid or spent.

Worked example#

BASE=https://kino.dpdns.org

# 1. Sign in
TOKEN=$(curl -fsS -X POST "$BASE/api/auth/signin" \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"…"}' | jq -r .session_token)

# 2. Add a machine, keep the install command
curl -fsS -X POST "$BASE/api/machines" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"homelab-pi"}' | jq -r .install_command

# 3. Later: where is it, and what do I dial it with?
curl -fsS -X POST "$BASE/api/machines/$AGENT_ID/connect" \
  -H "Authorization: Bearer $TOKEN" | jq