Skip to content
SkiMasque

The SkiMasque control protocol

All guides

Guide

Applies to: ✓ Mode 1 · ✓ Mode 2 · ✓ Mode 3 (you implement the server side)

The contract between a gateway (skimasque-server --control-plane <url>) and a control plane. SkiMasque publishes this so that a fully self-hosted deployment is possible without SkiMasque Cloud: anything that serves these endpoints with these JSON shapes can drive a stock open-source gateway.

The Rust types are the skimasque-protocol crate; this page is the human-readable reference. PROTOCOL_VERSION is "v1".

Shape

  • Plain HTTPS, JSON request and response bodies.
  • Two audiences:
    • Gateway ↔ control plane (documented here) — the enforcement-relevant surface.
    • Client ↔ control plane — skimasque login, org and policy management, usage and audit queries. Management-only, off the enforcement path; summarised below but not part of the stable contract yet.
  • After registration a gateway authenticates every call with Authorization: Bearer <secret>, where <secret> is the value it received in the registration response. POST /v1/gateways/register and GET /v1/orgs/{org}/signing-key are unauthenticated (a one-time token and a public key, respectively).

Invariant

The control plane decides desired state; the gateway enforces. A gateway that loses the control plane keeps enforcing its last cached policy and never fails open. Nothing here lets a control plane reach into live enforcement — the most a control plane does is publish policy the gateway then pulls, and sign credentials the gateway then verifies.


Gateway ↔ control plane endpoints

POST /v1/gateways/register

Register a new gateway. Unauthenticated; the one-time token is the credential.

Request:

{
  "registration_token": "skmreg_…",
  "name": "gw-eu-west-1",
  "labels": { "env": "prod", "region": "eu-west-1" }
}

labels may be omitted. Response 200:

{ "gateway_id": "gw_…", "org_id": "org_…", "secret": "…" }

The gateway persists all three under --control-plane-state. The token is spent; a restart reuses the stored identity. A spent or expired token → 403 bad_registration_token.

GET /v1/gateways/{id}/policy

Poll for policy. Authorization: Bearer <secret>.

  • If-None-Match: "<version>" makes it conditional on the revision the gateway already holds.
  • ?wait=<seconds> long-polls: the control plane holds the request open up to that long, answering as soon as a newer revision is published.

Responses:

StatusMeaning
200 + bodya newer revision; body below
304nothing newer than If-None-Match
204the org has never published a policy — the gateway exits ("the control plane has no policy for this gateway's organisation yet") and restart-loops until one exists

200 body:

{
  "version": 7,
  "documents": [
    { "name": "production.toml", "text": "name = \"production\"\n[match]\n…" }
  ]
}

version is also the next ETag value. The gateway writes each document to its policy-dir cache atomically and enforces the set.

POST /v1/gateways/{id}/heartbeat

Liveness and usage counters. Authorization: Bearer <secret>.

{
  "status": "online",
  "policy_version": 7,
  "usage": { "tunnels_opened": 128, "bytes_to_target": 9000000, "bytes_to_client": 4000000 }
}

status is "online" or "degraded" (the latter once the gateway has been past its soft policy lease with the control plane unreachable — it is still enforcing). usage is cumulative-since-start and may be omitted. The control plane marks a gateway offline in the fleet view if heartbeats lapse.

PUT /v1/gateways/{id}/labels

Declare (or update) the gateway's labels for policy targeting. Idempotent.

{ "labels": { "env": "prod", "region": "eu-west-1" } }

POST /v1/gateways/{id}/credentials

Ask the control plane to sign a platform credential for a workload identity the gateway has already verified from the runner's OIDC token. Authorization: Bearer <secret>.

{
  "identity": { "organization": "acme", "repository": "acme/widget", "workflow": "deploy.yml", "git_ref": "refs/heads/main" },
  "subject": "repo:acme/widget:ref:refs/heads/main",
  "ttl_seconds": 3600
}

Response:

{ "credential": "<JWT>", "expires_in": 3600 }

The credential is signed with the org's Ed25519 private key. expires_in is the actual lifetime after any server-side cap. The gateway may fall back to a locally signed HS256 credential if this endpoint is unreachable (unless --control-plane-no-credential-fallback).

GET /v1/gateways/{id}/revocations

The agent sessions that have been ended and whose credentials could still be presented. Long-polls like the policy endpoint: ?generation=<n> is the generation from the last answer, ?wait=<seconds> holds the request open (capped at 60), and 304 means nothing has changed. A 200 is always the complete current list, never a delta:

{ "generation": 7, "revoked": [ { "sid": "sess_3f9c…", "expires_at_ms": 1790000000000 } ] }

A gateway refuses new tunnels for a listed sid, closes its open ones, and persists the list. A platform gateway uses GET /v1/platform/gateways/{id}/revocations, which covers every org it serves.

GET /v1/orgs/{org}/signing-key

The org's Ed25519 public key. Unauthenticated. The gateway verifies control-plane-minted credentials against this offline, with no further calls.

{
  "org_id": "org_…",
  "algorithm": "ed25519",
  "public_key_b64": "…",            // raw 32-byte key, standard base64
  "previous_public_key_b64": "…"     // present across a rotation, until old credentials expire
}

The gateway re-fetches this on --control-plane-signing-key-interval (default 1h) so an in-place rotation is picked up without a restart.

GET /v1/gateways/{id}/audit/head

The tail of this gateway's audit chain, so a restart resumes its sequence.

{ "seq": 4210, "hash": "…" }        // seq 0, hash = 64 zeros, before anything is shipped

POST /v1/gateways/{id}/audit

Ship a hash-chained batch of audit events. Authorization: Bearer <secret>.

{
  "events": [
    { "seq": 4211, "prev_hash": "<hash of 4210>", "event_json": "{…the audit event verbatim…}" }
  ]
}

Each link is sha256(seq_be || prev_hash || event_json) as lowercase hex (skimasque_protocol::audit_hash). The control plane recomputes the chain and rejects a break (409). Response:

{ "head_seq": 4211 }

Audit shipping is fail-static: a ship failure never blocks enforcement, and the gateway's local JSONL sink stays authoritative.


Platform gateway endpoints (Mode 1)

A platform gateway is SkiMasque Cloud's shared, multi-tenant gateway. It belongs to no single organisation. Types live in skimasque_protocol::platform. A customer-operated gateway (Mode 2) never calls these endpoints, and a platform gateway is refused by the single-org endpoints above (403 platform_gateway).

Every refusal is 403 or 422 with a body { "code": "<code>", "message": "<sentence for a person>" }. The codes are owner_not_verified, over_cap, unknown_org, not_platform_gateway and platform_gateway.

POST /v1/platform/register

Register with a one-time platform registration token, issued by the operator (admin token). Unauthenticated.

{ "registration_token": "skmreg_…", "name": "shared-1", "labels": { "region": "us" } }

Response: { "gateway_id": "gw_…", "secret": "…" }. There is no org_id.

GET /v1/platform/gateways/{id}/tenants

The tenant list, with the same If-None-Match: "<version>" and ?wait=<seconds> long-poll semantics as the policy poll.

{
  "version": 42,
  "tenants": [
    {
      "org_id": "org_…",
      "slug": "acme",
      "owners": ["acme", "octocat"],
      "policy": { "version": 7, "documents": [ { "name": "prod.toml", "text": "…" } ] },
      "signing_key": { "org_id": "org_…", "algorithm": "ed25519", "public_key_b64": "…" }
    }
  ]
}

policy is null until the organisation publishes, and every request for it is then denied. Documents are already filtered to those whose label target the gateway satisfies. owners are lowercase GitHub logins.

  • owner_ids maps each owner login to GitHub's numeric account id when one is recorded. A gateway must match the OIDC repository_owner_id against it.

A job is resolved to a tenant at token exchange:

  • its OIDC audience must be exactly https://<gateway host>/o/<slug>;
  • its GitHub owner (repository_owner, carried as the identity's organization) must be in that tenant's owners.

POST /v1/platform/gateways/{id}/credentials

{ "org_id": "org_…", "identity": { "organization": "acme", "repository": "acme/widget" }, "ttl_seconds": 900 }

The control plane re-checks the owner and the plan allowance, then signs with that organisation's key. The credential carries a top-level org_id claim. The response is the same as the single-org mint: { "credential": "<JWT>", "expires_in": 900 }.

  • owner_id carries the OIDC repository_owner_id. The control plane refuses the mint (owner_not_verified) when the owner's claim records an id and this is different or absent.

A platform gateway has no local fallback. If this endpoint is unreachable, token exchange fails.

GET /v1/platform/gateways/{id}/audit/head and POST /v1/platform/gateways/{id}/audit

As the single-org audit endpoints, but each event names its organisation:

{ "events": [ { "org_id": "org_…", "seq": 12, "prev_hash": "…", "event_json": "{…}" } ] }

The chain is per gateway and hashed exactly as in the single-org protocol. The control plane files each event under its org_id, and rejects the whole batch (422 unknown_org) if any event names an organisation that is not a tenant.

POST /v1/platform/gateways/{id}/heartbeat

{ "status": "online", "tenants_version": 42, "usage_by_org": { "org_…": { "tunnels_opened": 3, "bytes_to_target": 0, "bytes_to_client": 0 } } }

Usage counters are cumulative since the gateway process started, per organisation, as in the single-org heartbeat.

Client ↔ control plane endpoints

Not part of the stable contract; listed so a self-hosted control plane knows the surface skimasque login / org / audit / status and the dashboard expect.

Method + pathPurpose
POST /v1/auth/device, /v1/auth/device/poll, /v1/auth/logoutthe GitHub OAuth device flow, brokered by the control plane
GET /v1/methe signed-in developer
POST /v1/orgs, GET /v1/orgs, GET /v1/orgs/{org}organisations
POST/GET /v1/orgs/{org}/members, DELETE /v1/orgs/{org}/members/{user}, PUT …/rolemembership and roles
POST /v1/orgs/{org}/registration-tokensmint a one-time gateway token
POST /v1/orgs/{org}/policy, GET …/policypublish / read a policy revision
POST /v1/orgs/{org}/policy/simulateevaluate a hypothetical request against a revision
GET/POST /v1/orgs/{org}/signing-key, …/signing-key/rotatethe org signing key
GET /v1/orgs/{org}/gatewaysthe fleet
GET /v1/orgs/{org}/usage, …/usage/historyusage totals and the daily series
GET /v1/orgs/{org}/auditquery shipped decisions ({ events, next_cursor })
POST/GET /v1/orgs/{org}/agent-sessionsstart an agent session (AgentSessionRequest → AgentSessionResponse, with the credential) / list recent ones (AgentSessionView)
GET/PUT /v1/orgs/{org}/credential-limitshow long credentials of each kind may live: read (any member) / lower the per-kind ceiling, in seconds (owner-only; above the built-in ceiling is refused)
POST /v1/orgs/{org}/agent-sessions/{sid}/endend one now, and the sessions delegated from it (EndedSession)

Tunnel request headers

A client opening a tunnel (CONNECT for TCP, extended CONNECT with :protocol connect-udp for UDP) may send, besides Proxy-Authorization:

HeaderMeaning
X-Masque-Application: <name>WHAT — the application the client declares. Policy rules match on it. Session context, not an authenticated fact.
X-Masque-Policy: <name>A pin. The gateway selects the policy for the client's identity exactly as it would without the header; if the selected policy is not <name> the tunnel is refused 403 with Proxy-Status: …; error=destination_prohibited; details="Policy \"<name>\" does not apply to this identity; …". The header never selects a policy, so it can only narrow access. Audit events carry it as requested_policy.

Both are part of the open protocol: any gateway, SkiMasque Cloud or self-hosted, honours them the same way.

Health

GET /healthz and GET /readyz are unauthenticated. GET /metrics (Prometheus) is gated by the admin token.