Skip to content
API

Portwing API & Transport

Standard request signing, controller-owned Docker transport, the Ed25519 key registry, and the portwing/1.0 edge endpoint.

The Portwing WebSocket gateway and key-registry API are enabled by default and follow the published portwing/1.0 compatibility policy. DD_EXPERIMENTAL_PORTWING=false remains available only as an emergency disable for new edge connections.

Edge mode inverts the normal drydock connection model. In the standard setup the controller dials out to each remote agent. Edge mode is for agents that sit behind NAT or a firewall and cannot accept inbound connections: the portwing agent dials out to drydock over an encrypted WebSocket (wss://), and drydock registers the connection as if the agent had been reached normally. No inbound port on the agent host is needed. The chain is sockguard → portwing (edge client) → drydock (this endpoint).

For agents that accept inbound connections from the controller, see the Agent API.


Standard Mode Ed25519 request signing

Standard Mode can authenticate each controller request with a shared X-Dd-Agent-Secret token or with Portwing's per-request Ed25519 scheme. With DD_AGENT_{name}_AUTHMODE=ed25519, Drydock sends these five headers and does not send the shared secret:

HeaderValue
X-Portwing-Key-IDRegistered 16-character public-key id
X-Portwing-TimestampUnix timestamp in seconds
X-Portwing-NonceFresh request nonce
X-Portwing-SignatureBase64url Ed25519 signature
X-Portwing-Signature-Version2

Signature version 2 signs the following UTF-8 bytes with no trailing newline:

METHOD
REQUEST-TARGET
<SHA-256 hex of the exact request body bytes>
<unix-timestamp-seconds>
<nonce>

REQUEST-TARGET is the exact origin-form target sent on the wire: the escaped path followed by ? and the unmodified raw query when present. For example, /v1.44/containers/json?all=1&filters=%7B%7D is signed exactly as shown. Percent escapes, query parameter order, repeated parameters, and raw query bytes must not be decoded, normalized, or reordered. This differs from the Edge hello signature below, whose canonical WebSocket path remains fixed by the portwing/1.0 protocol.


Ed25519 authentication model

Edge connections use public-key authentication; no session cookie or shared secret is involved.

  1. The operator generates an Ed25519 keypair on the agent host and registers the public key with drydock via the key registry REST API below (or preloads it at startup — see preloading keys).
  2. drydock stores the raw 32-byte public key (as standard base64) and derives a key ID: hex(SHA-256(raw 32-byte pubkey)[0:8]), yielding exactly 16 lowercase hex characters.
  3. When the agent connects it sends a signed hello frame as the first WebSocket message. The frame includes the pubKeyId, a Unix timestamp (seconds), a random 32-hex-char nonce, and an Ed25519 signature.
  4. drydock verifies the timestamp is within ±60 seconds of server time and that the nonce has not been seen before in that window. Both checks guard against replay attacks.
  5. Only after a successful signature verification is the nonce committed to the replay cache.

The signature is computed over a canonical, newline-joined message (SigV4-style), so the agent and server derive the identical bytes independently:

GET
/api/portwing/ws
<SHA-256 hex of the empty request body>
<unix-timestamp-seconds>
<nonce>

The body hash is the SHA-256 of an empty body (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855), and the path segment is always the literal /api/portwing/ws — see the WebSocket endpoint note on the versioned alias.


Key registry

The key registry is mounted at /api/v1/portwing/keys. Unlike the WebSocket endpoint below, there's no unversioned alias here: the bare /api/* prefix was removed in v1.6.0 — the unversioned path now fails fast with a 410-style rejection instead of routing anywhere (see Deprecations). All routes require an authenticated drydock session (same-origin or equivalent).

List keys

Returns all registered keys — both active and revoked.

curl http://drydock:3000/api/v1/portwing/keys

[
  {
    "keyId": "3f8a1c2e9b047d56",
    "pubkey": "kPGd2xqT8vWzN4cRfYbLmJhQ5sUaE7nVwXyZ0AB1CdE=",
    "label": "prod-agent-us-east",
    "createdAt": "2026-05-01T09:00:00.000Z",
    "revokedAt": null
  }
]

Each record has the shape:

FieldTypeDescription
keyIdstring16 lowercase hex chars — hex(SHA-256(raw pubkey)[0:8])
pubkeystringBase64-standard encoded raw 32-byte Ed25519 public key (44 chars)
labelstringHuman-readable name supplied at registration
createdAtstringISO-8601 UTC timestamp
revokedAtstring | nullISO-8601 UTC revocation timestamp, or null while active

Register a key

curl -X POST http://drydock:3000/api/v1/portwing/keys \
  -H "Content-Type: application/json" \
  -d '{
    "pubkeyBase64": "kPGd2xqT8vWzN4cRfYbLmJhQ5sUaE7nVwXyZ0AB1CdE=",
    "label": "prod-agent-us-east"
  }'

HTTP/1.1 201 Created

{
  "keyId": "3f8a1c2e9b047d56",
  "label": "prod-agent-us-east",
  "createdAt": "2026-05-01T09:00:00.000Z"
}

Request body

FieldTypeRequiredDescription
pubkeyBase64stringYesStandard base64-encoded raw 32-byte Ed25519 public key
labelstringYesHuman-readable identifier for this key

Status codes

CodeMeaning
201Key registered; response body contains keyId, label, createdAt
400Missing or malformed fields (non-string, empty string, invalid base64, wrong key length)
409A non-revoked key with this keyId is already registered

Revoke a key

Revoked keys are rejected on the next connection attempt, and any live session already authenticated under that key is disconnected immediately (error frame with code unknown-key, close code 1008). Revocation also releases every agent-name bound to that key, so a different key can claim the name on its next hello. The record is retained (with revokedAt set) for audit purposes.

curl -X DELETE http://drydock:3000/api/v1/portwing/keys/3f8a1c2e9b047d56

HTTP/1.1 204 No Content

Status codes

CodeMeaning
204Key revoked
400Invalid keyId format — must be exactly 16 lowercase hex characters
404No key with this keyId found

Preloading keys at startup

Set DD_PORTWING_AUTHORIZED_KEYS to the path of an authorized_keys-style file to bootstrap the registry at process startup, instead of (or in addition to) registering keys one at a time through the REST API:

# ed25519 <base64-standard raw 32-byte pubkey> [optional comment]
ed25519 kPGd2xqT8vWzN4cRfYbLmJhQ5sUaE7nVwXyZ0AB1CdE= prod-agent-us-east

Blank lines and #-prefixed comments are skipped. The file must not be world-readable — drydock refuses to load it and logs a warning otherwise (edge connections then require manual key registration). Loading is idempotent: a keyId that's already active is skipped, and a keyId present in the file but already revoked is left revoked (with a warning) rather than silently reactivated.


WebSocket endpoint

Edge agents connect to:

wss://drydock/api/portwing/ws

A versioned alias /api/v1/portwing/ws is also accepted and is signature-equivalent — the canonical path /api/portwing/ws is always used in signature computation regardless of which path the agent actually dialed. This dual-path acceptance is intentional protocol behavior (unlike the unversioned /api/log/stream WebSocket alias, which was removed in v1.6.0): adding or removing the /v1 prefix never forces a key rotation.

Subprotocol: portwing/1.0

Connection flow

  1. The agent opens a WebSocket upgrade to wss://drydock/api/portwing/ws with the portwing/1.0 subprotocol header. The upgrade is rejected before any WebSocket frame is read if the Origin header doesn't match the server (403) or the connection is rate-limited (429).
  2. The agent sends a hello frame as the first message, within 30 seconds — otherwise the connection is closed (code 1008, no error frame). The frame carries pubKeyId, timestamp, nonce, and signature as described under Ed25519 authentication model above, plus agentId, protocol (must be portwing/1.0), and version.
  3. drydock verifies the hello. On success it replies with a welcome frame carrying poll configuration and server version information.
  4. The connection is then live: the agent multiplexes container-sync, exec, logs, deletes, and controller-owned Docker watcher/update calls over correlated request-response-stream frames on the same WebSocket. The server also sends a ping frame every 30 seconds; an agent that misses two consecutive pongs (60 seconds of silence) is force-disconnected (close code 1001, reason ping timeout).

Hello frame

{
  "type": "hello",
  "data": {
    "agentId": "edge-host-01",
    "agentName": "edge-host-01",
    "protocol": "portwing/1.0",
    "version": "1.7.0-rc.1",
    "pubKeyId": "3f8a1c2e9b047d56",
    "timestamp": 1780329600,
    "nonce": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
    "signature": "..."
  }
}

agentId must match ^[a-zA-Z0-9_-]+$ (max 64 chars). agentName, if present, must be a string no longer than 256 raw characters — a non-string or oversized value is rejected with invalid-agent-name (close code 1008) before it is ever sanitized or logged. A valid agentName is then sanitized to a safe slug (lowercase, a-z0-9-, max 63 chars) and used as the agent's display/registry name; an empty, missing, or all-invalid-chars name falls back to portwing-edge-<agentId>.

Identity binding (anti-squat/anti-theft). A sanitized or fallback name has no cryptographic meaning of its own, so drydock binds each name to the pubKeyId that first authenticates a hello under it. A later hello that reuses the same name is admitted only if it authenticates under that same key; a hello for a name already bound to a different key is rejected with agent-name-claimed. The binding is not released on a plain disconnect or by age alone — it persists across restarts so a reconnecting agent keeps its name — but it is released the moment the owning key is revoked, freeing the name for a legitimately re-provisioned agent to reclaim. The binding registry is capped at 10,000 distinct names; only when admitting a brand-new name at that cap does drydock try evicting bindings that are both idle 24+ hours and not backing a live connection. It rejects the hello with registry-full if that doesn't free up room.

A name collision with an already-connected agent under the same key (or the in-flight reservation for a hello still being processed) is rejected with agent-already-connected. The frame also carries dockerVersion, hostname, and capabilities host-descriptor fields (and optionally drydockCompat, watcherTypes, triggerTypes) that aren't yet consumed on this path.

Welcome frame

{
  "type": "welcome",
  "data": {
    "pollInterval": 300,
    "config": {
      "drydockVersion": "1.7.0-rc.1",
      "supportedProtocols": "portwing/1.0",
      "serverCompatLevel": "1.4.0"
    }
  }
}

serverCompatLevel is 1.4.0 — the compatibility level actually implemented by this server, not a target for a future release. If the hello's drydockCompat field has a different major version, the connection is still accepted; drydock only logs a warning so operators can check the compat matrix.

Controller-owned Docker watcher, updates, and lifecycle actions

Drydock 1.6.0-rc.11+ activates this path only for Portwing 0.9.0+ watcher descriptors whose type is docker and whose configuration contains all three exact marker values:

{
  "transport": "docker-api",
  "execution": "controller",
  "events": "portwing"
}

Drydock then runs its native Docker watcher, registry checks, and native Docker action controller-side. This capability surface includes single/batch updates, container start/stop/restart, update preview, and backup rollback. Dockerode connects to a loopback-only bridge on an ephemeral port using a random bearer credential. The bridge strips hop-by-hop and caller-supplied authorization headers before forwarding each Docker Engine request through the already-authenticated Portwing connection:

  • Standard Mode: HTTP requests use the configured agent token or the five-header Ed25519 signature-version-2 scheme.
  • Edge Mode: non-streaming calls use correlated request/response messages; Docker streaming targets use the correlated stream path.

Both Docker updates and lifecycle actions use this controller-owned path over Standard or Edge mode. It does not advertise or invoke a Portwing remote trigger, and Portwing's legacy watcher/trigger POST endpoints may continue to return 501. Docker Compose actions are not part of this marker contract.

events: "portwing" keeps Portwing authoritative for Docker lifecycle events. The delegated native watcher disables its own event subscription, preventing a duplicate Docker event stream through the bridge. Portwing inventory remains the raw runtime view, so its updateAvailable=false and updateKind=unknown defaults must not erase controller-owned enrichment. Drydock preserves its stored registry result, update state, policy, security, rollback, and operation fields when later inventory and lifecycle reports arrive.

Continuous container-log streaming

The authenticated edge connection carries live Docker logs without opening another port on the agent. Drydock starts a stream with:

{
  "type": "dd:container_log_request",
  "data": {
    "requestId": "log-8f31",
    "containerId": "8d3f…",
    "stream": true,
    "follow": true,
    "tail": 200,
    "timestamps": true,
    "since": 0,
    "until": 0
  }
}

A current Portwing agent responds with zero or more chunk frames, preserving the Docker stream:

{
  "type": "dd:container_log_chunk",
  "data": {
    "requestId": "log-8f31",
    "containerId": "8d3f…",
    "stream": "stderr",
    "logs": "2026-07-28T12:00:00Z warning\n"
  }
}

The agent finishes with dd:container_log_end (requestId, containerId, optional reason) or dd:container_log_error (requestId, containerId, error). When the browser closes its log WebSocket, Drydock sends dd:container_log_cancel with the same requestId and containerId; Portwing cancels the Docker request promptly.

requestId is mandatory and correlates concurrent viewers. Drydock caps each downstream viewer at 1 MiB of queued data, while Portwing uses a bounded controller send queue and admits at most 128 live log streams. A slow consumer is closed instead of allowing unbounded memory growth.

Mixed-version fleets remain compatible. An older Portwing agent ignores stream and returns the legacy dd:container_log_response; Drydock renders that response as one stdout chunk and then ends the viewer. A newer agent still uses the legacy response for requests that omit stream: true.

Exec session protocol

This section is a protocol reference for agent implementers, not a usable end-user feature. drydock's controller implements the exec transport described below, but v1.6 exposes no exec UI or API for end users to actually open a session — nothing in the product calls it yet. Exec is Edge-only: it rides the same portwing/1.0 WebSocket as everything else on this page, and there is no equivalent on the standard inbound-agent HTTP protocol.

Like container-log streaming, exec sessions are multiplexed over the same authenticated connection. drydock starts a session with:

{
  "type": "exec_start",
  "data": {
    "execId": "0190f3c2-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "containerId": "8d3f…",
    "cmd": ["/bin/sh"],
    "user": "root",
    "cols": 80,
    "rows": 24,
    "tty": true
  }
}

The agent confirms the process started with exec_ready (execId), then streams base64-encoded output chunks:

{
  "type": "exec_output",
  "data": {
    "execId": "0190f3c2-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "data": "bHMgLWxhCg=="
  }
}

drydock sends stdin the same way with exec_input (execId, base64 data) and terminal resizes with exec_resize (execId, cols, rows). Either side ends the session with exec_end (execId, optional reason); drydock's current handler tears down the local session on receipt but does not yet inspect the reason field. Up to MAX_EXEC_SESSIONS (100) concurrent exec sessions are tracked per edge connection — startExec rejects once that limit is reached.

Protocol limits

ParameterValue
Clock skew window±60 seconds
Nonce format32 lowercase hex characters
Max frame payload16 MB
Hello timeout30 seconds
Ping interval / pong-miss threshold30 seconds / 2 missed cycles (60 s of silence)

Error frames

If the hello is rejected, drydock sends a JSON error frame before closing:

{
  "type": "error",
  "data": {
    "message": "Ed25519 signature verification failed",
    "code": "bad-signature"
  }
}

Close code is 1008 for every rejection except an internal signing-library failure (internal-error, close code 1011, distinct from a merely-rejected signature). A bare hello timeout closes with 1008 and sends no error frame at all.

Error codes: parse-error, expected-hello, invalid-agent-name, protocol-mismatch, no-auth, ed25519-required, unknown-key, timestamp-skew, bad-nonce, replay, bad-signature, internal-error, rate-limited, agent-name-claimed, registry-full, agent-already-connected.


Enrolling a portwing agent

  1. Generate a keypair on the agent host:

    openssl genpkey -algorithm ed25519 -out agent.key
    openssl pkey -in agent.key -pubout -outform DER | tail -c 32 | base64

    The last command prints the 44-character base64 public key to register.

  2. Register the public key with drydock using POST /api/v1/portwing/keys (or add it to the file pointed at by DD_PORTWING_AUTHORIZED_KEYS). Save the returned keyId — the agent needs it in its configuration.

  3. Configure and start the agent. Point portwing at:

    wss://drydock/api/portwing/ws

    and supply the private key file and keyId. The agent will dial out and authenticate automatically.