Drydock Integration
How Portwing serves as a Drydock agent — the Drydock-native container model, REST/SSE endpoints, the adapter that maps Drydock expectations onto Docker, and how a Drydock controller registers and communicates with the agent.
Portwing is designed to be a drop-in replacement for the original Drydock Node.js agent. It speaks the same REST and SSE protocol that Drydock's AgentClient expects, and adds an outbound WebSocket path (Edge Mode) for NAT/firewall-constrained hosts.
Architecture
Drydock supports two agent connectivity patterns. Portwing implements both.
Standard Mode (fully implemented): The Drydock controller opens an inbound HTTP connection to each remote host's Portwing agent. Portwing runs an HTTP server; the controller polls /api/* and holds a long-lived SSE stream.
Edge Mode (stable portwing/1.0): Portwing dials outbound to the Drydock controller's /api/portwing/ws WebSocket endpoint. All communication is multiplexed over the single connection. This works behind NAT and dynamic IPs.
Edge Mode is implemented on both sides and is covered by a real multi-agent reconnect/load soak. Drydock v1.6.0-rc.11+ is the minimum for Portwing v0.9.0's complete controller-owned watcher/update behavior. Older 1.6 prereleases and Drydock 1.5 may remain wire-compatible, but do not provide that full feature contract.
Standard Mode: Handshake Sequence
Verified against Drydock v1.6.0-rc.11+ (app/agent/AgentClient.ts).
When a Drydock controller connects to a Portwing agent in Standard Mode, the following sequence occurs:
- The controller opens
GET /api/eventswith theX-Dd-Agent-Secretheader. Portwing responds with atext/event-stream. - Portwing immediately emits
dd:ackon the stream. The controller'shandleAckEvent()triggershandshake(). - The controller calls
GET /api/containers, thenGET /api/watchers, thenGET /api/triggersin sequence. - The SSE stream remains open. Portwing pushes
dd:container-added,dd:container-updated, anddd:container-removedevents as container state changes. - After every poll cycle, Portwing also emits
dd:watcher-snapshotso the controller can prune any stale entries. A reconnecting controller receives the current snapshot immediately afterdd:ack— no waiting for the next poll interval.
Endpoints Portwing Serves (Standard Mode)
All /api/* endpoints require an X-Dd-Agent-Secret or X-Portwing-Token header. The /health endpoint is unauthenticated.
| Drydock Call | Portwing Endpoint | Method | Notes |
|---|---|---|---|
AgentClient.startSse() | GET /api/events | GET | SSE stream; emits dd:ack on connect |
AgentClient._doHandshake() | GET /api/containers | GET | Returns []Container JSON |
AgentClient._doHandshake() | GET /api/watchers | GET | Returns []ComponentDescriptor |
AgentClient._doHandshake() | GET /api/triggers | GET | Returns []ComponentDescriptor (empty) |
AgentClient.getWatcher() | GET /api/watchers/{type}/{name} | GET | Single watcher descriptor |
AgentClient.getContainerLogs() | GET /api/containers/{id}/logs | GET | Plain text or {"logs":"..."} |
AgentClient.deleteContainer() | DELETE /api/containers/{id} | DELETE | 204 on success |
AgentClient.watch() | POST /api/watchers/{type}/{name} | POST | 501 — legacy route; controller runs the watcher through the Docker proxy |
AgentClient.watchContainer() | POST /api/watchers/{type}/{name}/container/{id} | POST | 501 — same controller-owned path |
AgentClient.runRemoteTrigger() | POST /api/triggers/{type}/{name} | POST | 501 — Portwing advertises no remote trigger |
AgentClient.runRemoteTriggerBatch() | POST /api/triggers/{type}/{name}/batch | POST | 501 — same compatibility behavior |
AgentClient.getLogEntries() | GET /api/log/entries | GET | Returns [] (no in-memory buffer) |
| Drydock health probe | GET /health | GET | {"status":"ok"} — no auth |
The 501 responses on POST /api/watchers/* and POST /api/triggers/* are intentional compatibility behavior. A compatible Drydock controller runs its native Docker watcher and update trigger through Portwing's authenticated transparent Docker API proxy instead of delegating execution to these legacy endpoints. Portwing supplies the proxy transport, container inventory, and lifecycle events.
SSE Event Shapes
Drydock parses SSE frames as data: <json>\n\n. Each frame is a JSON object with type and data fields (AgentClient.ts:659).
dd:ack
Sent on every new SSE connection. Drydock reads version, os, arch, cpus, memoryGb, uptimeSeconds, lastSeen, logLevel, and pollInterval from the payload.
{
"type": "dd:ack",
"data": {
"version": "0.9.0",
"os": "linux",
"arch": "amd64",
"cpus": 4,
"memoryGb": 15.6,
"uptimeSeconds": 123,
"lastSeen": "2026-06-11T12:00:00Z",
"logLevel": "info",
"pollInterval": "5m0s",
"containers": {"total": 3, "running": 2, "stopped": 1},
"images": 2
}
}memoryGb is read from /proc/meminfo (the binary stays cgo-free) and rounded to one decimal GiB; non-Linux hosts report 0, which Drydock accepts. pollInterval is the agent's DD_POLL_INTERVAL rendered as a Go duration string — the field is informational and displayed as-is. Portwing 0.5.x and earlier sent memoryGb: 0 and omitted logLevel/pollInterval.
pollInterval takes several different shapes depending on the source: a Go duration string ("5m0s", Standard Mode dd:ack), a cron expression (Drydock's own legacy Node.js agent), and a bare integer string ("300") on Drydock's REST AgentInfo surface. Edge Mode's welcome frame is different again — it sends pollInterval as a JSON number (e.g. 300), not a string (see below). Treat it as an opaque, display-only value in all cases; do not parse it.
dd:container-added / dd:container-updated
{"type": "dd:container-added", "data": {}}The data field is a full Container object (see Container Object Shape below).
dd:container-removed
Drydock reads only .data.id from this event. Portwing also sends name as a harmless extra field.
{"type": "dd:container-removed", "data": {"id": "abc123", "name": "web"}}dd:watcher-snapshot
Sent after every container poll cycle and immediately after dd:ack to each newly connected SSE client. Drydock uses this event to prune stale containers — containers absent from the snapshot are removed from the controller's store.
{
"type": "dd:watcher-snapshot",
"data": {
"watcher": {"type": "docker", "name": "docker", "configuration": {}},
"containers": []
}
}Container Object Shape
Portwing's GET /api/containers and all container events return objects in this shape. Drydock passes them directly to storeContainer.insertContainer() / storeContainer.updateContainer().
| Field | Type | Notes |
|---|---|---|
id | string | Docker container ID |
name | string | Container name (no leading /) |
displayName | string | From dd.display.name label, falls back to name |
displayIcon | string? | From dd.display.icon label |
status | string | running, stopped, paused, restarting, dead, created |
watcher | string | "docker" or value from dd.watch label |
agent | string? | Set by the Drydock controller; stripped from agent responses |
image.id | string | Image SHA |
image.registry | object | {name:"unknown", url:"docker.io"}; the controller assigns its configured registry component |
image.name | string | Image name |
image.tag | object | {value:"latest", semver:false} |
image.digest | object | {watch:false, value:"sha256:…"} |
image.architecture | string | Image architecture, with agent architecture as fallback |
image.os | string | Image OS, with agent OS as fallback |
updateAvailable | bool | Always false in Portwing's raw inventory; Drydock enriches its controller-side watcher result |
updateKind | object | Always {"kind":"unknown"} |
labels | object? | All Docker labels |
details | object? | Runtime details; ports and volumes use Drydock's string-array wire representation |
Drydock Container Labels
Portwing reads dd.* labels from Docker containers to populate the Container object and configure watcher behaviour.
| Label | Purpose |
|---|---|
dd.watch | Set to true to monitor this container |
dd.tag.include | Regex for tag inclusion |
dd.tag.exclude | Regex for tag exclusion |
dd.tag.transform | Tag transformation rule |
dd.display.name | Custom display name in Drydock UI |
dd.display.icon | Custom icon in Drydock UI |
dd.group | Container grouping |
dd.link.template | Custom link template |
Watcher Component Descriptor Shape
GET /api/watchers returns an array; GET /api/watchers/{type}/{name} returns a single descriptor.
Drydock reads type, name, configuration, and metadata from each descriptor.
[{
"type": "docker",
"name": "docker",
"configuration": {
"description": "Watches Docker containers for updates via Docker Engine API",
"capabilities": ["container-sync", "labels"],
"transport": "docker-api",
"execution": "controller",
"events": "portwing"
}
}]Watcher Delegation Model
The watcher descriptor declares transport: "docker-api",
execution: "controller", and events: "portwing". Together these fields
define the integration contract:
- A compatible Drydock controller runs its native Docker watcher and update trigger controller-side.
- Docker inspection and update calls traverse Portwing's authenticated,
transparent proxy: inbound HTTP in Standard Mode or correlated
request/responsemessages over the outbound WebSocket in Edge Mode. - Portwing reports inventory, reads
dd.*labels, and emits Docker lifecycle events.events: "portwing"prevents Drydock from opening a second Docker event stream through the proxy. - Drydock records the watcher result and owns update orchestration and update operation events.
updateAvailable is always false and updateKind is always "unknown" in
Portwing's raw inventory. That metadata is not ignored: Drydock enriches its
controller-side result after running the native watcher through Portwing.
This complete watcher/update execution model requires Drydock
v1.6.0-rc.11 or later in both Standard and Edge modes. The marker fields
are additive and DrydockCompat remains 1.4.0, so an older controller can be
wire-compatible without implementing these feature semantics.
Portwing advertises no remote trigger: GET /api/triggers, edge triggerTypes,
and the trigger portion of component sync remain empty. The dd:trigger
protocol vocabulary is retained for wire compatibility, but the watcher and
trigger POST endpoints continue to return 501 for older clients.
Edge Mode: WebSocket Handshake
When DRYDOCK_URL is set, Portwing dials outbound to DRYDOCK_URL/api/portwing/ws and sends portwing/1.0 as the protocol identifier inside its hello message — an application-level field, not a negotiated Sec-WebSocket-Protocol sub-protocol header.
The sequence below is the production handshake, implemented on the agent side in internal/edge/client.go and internal/protocol/messages.go and on the controller side in Drydock v1.6.0-rc.11+'s /api/portwing/ws endpoint. The controller requires an Ed25519-signed hello.
Drydock v1.6.0-rc.11+ supplies the complete v0.9 watcher/update contract. Earlier controllers may accept the stable wire protocol but lack the coordinated execution markers and ordering.
Handshake sequence:
- Portwing connects via WSS to
/api/portwing/ws. - Portwing sends a
hellomessage (see payload below). - Controller responds with
welcomecontaining the poll interval. - Portwing sends
dd:component_sync(watcher/trigger descriptors) beforedd:container_sync(full container inventory), then sends an initialmetricspayload. The ordering lets Drydock establish controller-owned update state before it ingests Portwing's raw inventory.
hello Message
{
"type": "hello",
"data": {
"version": "1.0.0",
"protocol": "portwing/1.0",
"agentId": "uuid",
"agentName": "my-server",
"pubKeyId": "a3f2b1c9d8e7f6a4",
"timestamp": 1749820800,
"nonce": "0123456789abcdef0123456789abcdef",
"signature": "<base64url-ed25519-signature>",
"dockerVersion": "27.0.3",
"hostname": "my-server",
"capabilities": [
"compose", "exec", "metrics", "events",
"dd:container-sync", "dd:logs"
],
"drydockCompat": "1.4.0",
"watcherTypes": ["docker"],
"triggerTypes": []
}
}All Edge Mode JSON application messages are wrapped in an Envelope ({"type": ..., "data": ...}, internal/protocol/messages.go) — the fields above live under data, not at the top level. (WebSocket ping/pong/close control frames are not wrapped.)
When PRIVATE_KEY_FILE is set, the hello carries the pubKeyId, timestamp, nonce, and signature fields shown above, and Drydock verifies the signature against the agent's registered public key. The tokenHash field (SHA-256 of the shared token) is only a fallback for non-edge endpoints — the Drydock /api/portwing/ws endpoint rejects token-hash hellos with ed25519-required.
Edge Mode Message Types
Core protocol:
| Type | Direction | Purpose |
|---|---|---|
hello | Agent → Controller | Auth + capability exchange |
welcome | Controller → Agent | Connection accepted |
request | Controller → Agent | Docker API request (with requestId), including controller-owned watcher/update calls |
response | Agent → Controller | Docker API response (correlated by requestId) |
stream | Bidirectional | Streaming data (logs, exec, build) |
stream_end | Bidirectional | End of stream |
metrics | Agent → Controller | Host metrics payload |
container_event | Agent → Controller | Docker lifecycle event; used instead of a duplicate controller event stream |
ping / pong | Either | Keepalive (30s default) |
error | Either | Error with optional code |
Exec (interactive docker exec sessions):
| Type | Direction | Purpose |
|---|---|---|
exec_start | Controller → Agent | Start an exec session (execId, containerId, cmd, user, cols, rows, tty) |
exec_ready | Agent → Controller | Exec session started and attached; safe to send input |
exec_input | Controller → Agent | Base64 stdin chunk for a live session |
exec_output | Agent → Controller | Base64 stdout/stderr chunk from a live session |
exec_resize | Controller → Agent | TTY resize (cols, rows) for a live session |
exec_end | Either | Session ended, with an optional reason |
If a session fails to start (for example, a sockguard exec-policy denial),
the agent's failStart path sends exec_end immediately instead of
exec_ready. Its reason field now carries the underlying Docker/sockguard
error body — e.g. not allowed by portwing preset or exec denied: privileged exec is not allowed — rather than a bare status code. Drydock's
edge agent adapter does not yet surface that reason to the user; today it
receives the frame but discards reason before it reaches Drydock's own
exec error display.
Drydock-specific (dd: namespace):
| Type | Direction | Purpose |
|---|---|---|
dd:container_sync | Agent → Controller | Full container inventory with update metadata |
dd:container_added | Agent → Controller | New container discovered |
dd:container_updated | Agent → Controller | Container state/metadata changed |
dd:container_removed | Agent → Controller | Container removed |
dd:component_sync | Agent → Controller | Watcher + trigger component descriptors |
dd:watch_request | Controller → Agent | Reserved legacy remote-watcher message; not used by the controller-owned Docker watcher contract |
dd:watch_response | Agent → Controller | Reserved legacy remote-watcher response |
dd:watch_container_request | Controller → Agent | Reserved legacy single-container watcher message |
dd:watch_container_response | Agent → Controller | Reserved legacy single-container watcher response |
dd:trigger_request | Controller → Agent | Reserved legacy remote-trigger message; no Portwing trigger is advertised |
dd:trigger_response | Agent → Controller | Reserved legacy remote-trigger response |
dd:container_log_request | Controller → Agent | Request container logs (requestId, stream, tail, since, until, follow, timestamps) |
dd:container_log_chunk | Agent → Controller | Continuous stdout/stderr chunk for a streaming request |
dd:container_log_end | Agent → Controller | Streaming request completed or was canceled |
dd:container_log_error | Agent → Controller | Streaming request failed |
dd:container_log_cancel | Controller → Agent | Cancel a streaming request when its viewer closes |
dd:container_log_response | Agent → Controller | Legacy bounded, one-shot container log data |
dd:container_delete_request | Controller → Agent | Request container removal |
dd:container_delete_response | Agent → Controller | Removal result (success/error, correlated by requestId) |
For continuous logs, Drydock sends stream:true with a mandatory requestId.
Portwing emits separate stdout/stderr chunks and then an end or error frame.
Closing the browser viewer sends dd:container_log_cancel, which cancels the
Docker request promptly. Portwing admits at most 128 live log streams and uses
the edge connection's bounded send queue; Drydock caps each downstream viewer
at 1 MiB. A stalled consumer is disconnected instead of growing memory without
limit.
Mixed-version fleets degrade gracefully. An older agent ignores stream and
returns dd:container_log_response; Drydock renders it as one stdout chunk and
ends the viewer. Requests without stream:true retain the bounded one-shot
response. Docker's 8-byte stream headers are removed and converted into the
chunk's stream field; a TTY container's raw stream is emitted as stdout.
A hello rejection is classified before the agent reconnects. Terminal codes
(ed25519-required, unknown-key, bad-signature, protocol-mismatch,
no-auth, invalid-agent-name, parse-error, expected-hello,
agent-name-claimed) stop the agent with an actionable error instead of
retrying forever; timing/capacity codes (timestamp-skew, replay,
rate-limited, registry-full, agent-already-connected, …) and any
unrecognized code are retried with backoff.
Environment Variable Reference
Portwing Side (agent)
| Variable | Purpose | Drydock counterpart |
|---|---|---|
TOKEN | Shared secret (preferred) | Drydock agent secret |
DD_AGENT_SECRET | Drydock agent secret | Drydock agent secret |
TOKEN_FILE | Path to file containing token | Drydock DD_AGENT_SECRET_FILE |
DD_AGENT_SECRET_FILE | Path to file containing token | Drydock DD_AGENT_SECRET_FILE |
TOKEN_HASH | Argon2id PHC hash of token (Standard Mode only) | n/a |
PORT | HTTP listen port (default 3000) | Drydock agent port |
BIND_ADDRESS | Standard default 0.0.0.0; Edge operations default 127.0.0.1 | n/a |
TLS_CERT / TLS_KEY | TLS certificate/key | Drydock agent certfile/keyfile |
CA_CERT | CA cert for Edge Mode TLS | Drydock agent cafile |
TLS_SKIP_VERIFY | Skip TLS verification in Edge Mode | n/a |
DRYDOCK_URL | Edge Mode controller URL; agent dials out to /api/portwing/ws | n/a |
AGENT_ID | UUID for this agent | Drydock registers via hello.agentId |
AGENT_NAME | Display name (default: hostname) | Drydock agent component name |
DD_POLL_INTERVAL | Container refresh interval in seconds (default 300) | n/a |
ADAPTER | drydock (default) or generic | n/a |
AGENT_NAME: in Edge Mode the (sanitized) hello.agentName sent by Portwing is now honored by Drydock as the agent's display name, as of Drydock dev/v1.6. Sanitization lowercases the name, replaces runs of non-[a-z0-9-] characters with a single -, trims leading/trailing -, and truncates to 63 characters; an empty result falls back to portwing-edge-<agentId>.
DD_POLL_INTERVAL is ignored in Edge Mode — the controller's welcome frame pollInterval is authoritative there (Drydock intentionally owns the refresh cadence in Edge Mode; this is deliberate, not a bug). It still applies as documented in Standard Mode.
For the full variable reference including Docker socket, metrics, and reconnect settings, see Configuration.
Drydock Controller Side
These are set in Drydock's agent component configuration, not environment variables on the Portwing host.
| Drydock config field | Description |
|---|---|
host | Portwing hostname or IP |
port | Portwing port (default 3000) |
secret | Shared secret — sent as X-Dd-Agent-Secret |
cafile | CA certificate for TLS verification |
certfile / keyfile | mTLS certificate pair |
DD_AGENT_ALLOW_INSECURE_SECRET=true | Allow the shared secret over plain HTTP |
Deploying for Standard Mode
Docker Compose (agent host)
services:
portwing:
image: ghcr.io/codeswhat/portwing:latest
environment:
TOKEN: "${DD_AGENT_SECRET}"
PORT: "3000"
AGENT_NAME: "my-server"
# The image runs as non-root UID 65532; grant the Docker socket's group:
# export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
group_add:
- "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "3000:3000"
restart: unless-stoppedDrydock Agent Component Configuration (controller)
agent:
my-server:
host: http://my-server-ip
port: 3000
secret: "${DD_AGENT_SECRET}"The TOKEN value on the Portwing side must match the secret value on the Drydock side.
Compatibility Matrix
For the N-way version matrix (portwing version × Drydock version × sockguard preset × wire compat constant), see COMPATIBILITY.md at the repo root — that file is the canonical source for "does version X of one tool work with version Y of another." The table below is feature-level detail: which individual endpoints/messages are implemented and verified against a specific Drydock build.
Verified against Drydock v1.6.0-rc.11+ (app/agent/AgentClient.ts, app/agent/api/).
| Feature | Status | Notes |
|---|---|---|
GET /api/events SSE stream | Compatible | sse.go:48; AgentClient.ts:717 |
dd:ack on connect | Compatible | sse.go:74; AgentClient.ts:1299 |
dd:ack field set | Compatible | All fields Drydock reads (AgentClient.ts:744–763) are present |
dd:ack memoryGb=0 | Compatible | Drydock treats 0 as valid |
dd:container-added SSE | Compatible | sse.go:174; AgentClient.ts:1303 |
dd:container-updated SSE | Compatible | sse.go:187; AgentClient.ts:1303 |
dd:container-removed SSE | Compatible | Drydock only reads .id; extra name field is harmless |
dd:watcher-snapshot SSE | Compatible | Emitted after every poll cycle and on SSE connect |
GET /api/containers returns []Container | Compatible | routes.go:27; AgentClient.ts:519 |
| Container object shape | Compatible | model.go; Drydock model/container.js |
GET /api/watchers returns []ComponentDescriptor | Compatible | Descriptor selects transport=docker-api, execution=controller, and events=portwing |
GET /api/watchers/{type}/{name} single descriptor | Compatible | routes.go:handleWatcherGet; AgentClient.ts:1552 |
GET /api/triggers returns [] | Compatible | Portwing advertises no remote trigger; Drydock uses its controller-owned update trigger through the Docker proxy |
GET /api/containers/{id}/logs plain text | Compatible | Drydock accepts both plain text and {"logs":"..."} |
DELETE /api/containers/{id} returns 204 | Compatible | routes.go:92; AgentClient.ts:1543 |
POST /api/watchers/{type}/{name} | Compatible (501) | Legacy route; Drydock runs its watcher through the transparent Docker proxy |
POST /api/watchers/{type}/{name}/container/{id} | Compatible (501) | Same controller-owned execution path |
POST /api/triggers/{type}/{name} | Compatible (501) | No Portwing remote trigger is advertised; Drydock applies updates through the proxy |
POST /api/triggers/{type}/{name}/batch | Compatible (501) | Same compatibility behavior |
GET /api/log/entries returns [] | Compatible | routes.go:handleLogEntries; AgentClient.ts:1503 |
X-Dd-Agent-Secret authentication | Compatible | server/http.go auth middleware; AgentClient.ts:73 |
/health unauthenticated | Compatible | routes.go:handleSimpleHealth; AgentClient.ts:152 |
Edge Mode WebSocket /api/portwing/ws | Stable (portwing/1.0) | Ed25519-only; correlated request/response messages carry controller-owned watcher/update Docker API calls. Continuous logs and the real fleet-soak gate require Portwing 0.8+. |
Related Pages
- Connection Modes — Standard vs Edge Mode detection and configuration
- Authentication — Shared secret, token hash, and Ed25519 key auth
- Configuration — Full environment variable reference
- Drydock repository — Controller source