Portwing API & Transport
Standard request signing, controller-owned Docker transport, the Ed25519 key registry, and the portwing/1.0 edge endpoint.
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:
| Header | Value |
|---|---|
X-Portwing-Key-ID | Registered 16-character public-key id |
X-Portwing-Timestamp | Unix timestamp in seconds |
X-Portwing-Nonce | Fresh request nonce |
X-Portwing-Signature | Base64url Ed25519 signature |
X-Portwing-Signature-Version | 2 |
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.
- 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).
- 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. - 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. - 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.
- 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:
| Field | Type | Description |
|---|---|---|
keyId | string | 16 lowercase hex chars — hex(SHA-256(raw pubkey)[0:8]) |
pubkey | string | Base64-standard encoded raw 32-byte Ed25519 public key (44 chars) |
label | string | Human-readable name supplied at registration |
createdAt | string | ISO-8601 UTC timestamp |
revokedAt | string | null | ISO-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
| Field | Type | Required | Description |
|---|---|---|---|
pubkeyBase64 | string | Yes | Standard base64-encoded raw 32-byte Ed25519 public key |
label | string | Yes | Human-readable identifier for this key |
Status codes
| Code | Meaning |
|---|---|
201 | Key registered; response body contains keyId, label, createdAt |
400 | Missing or malformed fields (non-string, empty string, invalid base64, wrong key length) |
409 | A 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 ContentStatus codes
| Code | Meaning |
|---|---|
204 | Key revoked |
400 | Invalid keyId format — must be exactly 16 lowercase hex characters |
404 | No 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-eastBlank 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/wsA 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
- The agent opens a WebSocket upgrade to
wss://drydock/api/portwing/wswith theportwing/1.0subprotocol header. The upgrade is rejected before any WebSocket frame is read if theOriginheader doesn't match the server (403) or the connection is rate-limited (429). - 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 carriespubKeyId,timestamp,nonce, andsignatureas described under Ed25519 authentication model above, plusagentId,protocol(must beportwing/1.0), andversion. - drydock verifies the hello. On success it replies with a welcome frame carrying poll configuration and server version information.
- 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
pingframe every 30 seconds; an agent that misses two consecutive pongs (60 seconds of silence) is force-disconnected (close code1001, reasonping 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/responsemessages; Docker streaming targets use the correlatedstreampath.
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
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
| Parameter | Value |
|---|---|
| Clock skew window | ±60 seconds |
| Nonce format | 32 lowercase hex characters |
| Max frame payload | 16 MB |
| Hello timeout | 30 seconds |
| Ping interval / pong-miss threshold | 30 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
-
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 | base64The last command prints the 44-character base64 public key to register.
-
Register the public key with drydock using
POST /api/v1/portwing/keys(or add it to the file pointed at byDD_PORTWING_AUTHORIZED_KEYS). Save the returnedkeyId— the agent needs it in its configuration. -
Configure and start the agent. Point
portwingat:wss://drydock/api/portwing/wsand supply the private key file and
keyId. The agent will dial out and authenticate automatically.