Skip to content

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:

  1. The controller opens GET /api/events with the X-Dd-Agent-Secret header. Portwing responds with a text/event-stream.
  2. Portwing immediately emits dd:ack on the stream. The controller's handleAckEvent() triggers handshake().
  3. The controller calls GET /api/containers, then GET /api/watchers, then GET /api/triggers in sequence.
  4. The SSE stream remains open. Portwing pushes dd:container-added, dd:container-updated, and dd:container-removed events as container state changes.
  5. After every poll cycle, Portwing also emits dd:watcher-snapshot so the controller can prune any stale entries. A reconnecting controller receives the current snapshot immediately after dd: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 CallPortwing EndpointMethodNotes
AgentClient.startSse()GET /api/eventsGETSSE stream; emits dd:ack on connect
AgentClient._doHandshake()GET /api/containersGETReturns []Container JSON
AgentClient._doHandshake()GET /api/watchersGETReturns []ComponentDescriptor
AgentClient._doHandshake()GET /api/triggersGETReturns []ComponentDescriptor (empty)
AgentClient.getWatcher()GET /api/watchers/{type}/{name}GETSingle watcher descriptor
AgentClient.getContainerLogs()GET /api/containers/{id}/logsGETPlain text or {"logs":"..."}
AgentClient.deleteContainer()DELETE /api/containers/{id}DELETE204 on success
AgentClient.watch()POST /api/watchers/{type}/{name}POST501 — legacy route; controller runs the watcher through the Docker proxy
AgentClient.watchContainer()POST /api/watchers/{type}/{name}/container/{id}POST501 — same controller-owned path
AgentClient.runRemoteTrigger()POST /api/triggers/{type}/{name}POST501 — Portwing advertises no remote trigger
AgentClient.runRemoteTriggerBatch()POST /api/triggers/{type}/{name}/batchPOST501 — same compatibility behavior
AgentClient.getLogEntries()GET /api/log/entriesGETReturns [] (no in-memory buffer)
Drydock health probeGET /healthGET{"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().

FieldTypeNotes
idstringDocker container ID
namestringContainer name (no leading /)
displayNamestringFrom dd.display.name label, falls back to name
displayIconstring?From dd.display.icon label
statusstringrunning, stopped, paused, restarting, dead, created
watcherstring"docker" or value from dd.watch label
agentstring?Set by the Drydock controller; stripped from agent responses
image.idstringImage SHA
image.registryobject{name:"unknown", url:"docker.io"}; the controller assigns its configured registry component
image.namestringImage name
image.tagobject{value:"latest", semver:false}
image.digestobject{watch:false, value:"sha256:…"}
image.architecturestringImage architecture, with agent architecture as fallback
image.osstringImage OS, with agent OS as fallback
updateAvailableboolAlways false in Portwing's raw inventory; Drydock enriches its controller-side watcher result
updateKindobjectAlways {"kind":"unknown"}
labelsobject?All Docker labels
detailsobject?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.

LabelPurpose
dd.watchSet to true to monitor this container
dd.tag.includeRegex for tag inclusion
dd.tag.excludeRegex for tag exclusion
dd.tag.transformTag transformation rule
dd.display.nameCustom display name in Drydock UI
dd.display.iconCustom icon in Drydock UI
dd.groupContainer grouping
dd.link.templateCustom 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:

  1. A compatible Drydock controller runs its native Docker watcher and update trigger controller-side.
  2. Docker inspection and update calls traverse Portwing's authenticated, transparent proxy: inbound HTTP in Standard Mode or correlated request/response messages over the outbound WebSocket in Edge Mode.
  3. 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.
  4. 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:

  1. Portwing connects via WSS to /api/portwing/ws.
  2. Portwing sends a hello message (see payload below).
  3. Controller responds with welcome containing the poll interval.
  4. Portwing sends dd:component_sync (watcher/trigger descriptors) before dd:container_sync (full container inventory), then sends an initial metrics payload. 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:

TypeDirectionPurpose
helloAgent → ControllerAuth + capability exchange
welcomeController → AgentConnection accepted
requestController → AgentDocker API request (with requestId), including controller-owned watcher/update calls
responseAgent → ControllerDocker API response (correlated by requestId)
streamBidirectionalStreaming data (logs, exec, build)
stream_endBidirectionalEnd of stream
metricsAgent → ControllerHost metrics payload
container_eventAgent → ControllerDocker lifecycle event; used instead of a duplicate controller event stream
ping / pongEitherKeepalive (30s default)
errorEitherError with optional code

Exec (interactive docker exec sessions):

TypeDirectionPurpose
exec_startController → AgentStart an exec session (execId, containerId, cmd, user, cols, rows, tty)
exec_readyAgent → ControllerExec session started and attached; safe to send input
exec_inputController → AgentBase64 stdin chunk for a live session
exec_outputAgent → ControllerBase64 stdout/stderr chunk from a live session
exec_resizeController → AgentTTY resize (cols, rows) for a live session
exec_endEitherSession 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):

TypeDirectionPurpose
dd:container_syncAgent → ControllerFull container inventory with update metadata
dd:container_addedAgent → ControllerNew container discovered
dd:container_updatedAgent → ControllerContainer state/metadata changed
dd:container_removedAgent → ControllerContainer removed
dd:component_syncAgent → ControllerWatcher + trigger component descriptors
dd:watch_requestController → AgentReserved legacy remote-watcher message; not used by the controller-owned Docker watcher contract
dd:watch_responseAgent → ControllerReserved legacy remote-watcher response
dd:watch_container_requestController → AgentReserved legacy single-container watcher message
dd:watch_container_responseAgent → ControllerReserved legacy single-container watcher response
dd:trigger_requestController → AgentReserved legacy remote-trigger message; no Portwing trigger is advertised
dd:trigger_responseAgent → ControllerReserved legacy remote-trigger response
dd:container_log_requestController → AgentRequest container logs (requestId, stream, tail, since, until, follow, timestamps)
dd:container_log_chunkAgent → ControllerContinuous stdout/stderr chunk for a streaming request
dd:container_log_endAgent → ControllerStreaming request completed or was canceled
dd:container_log_errorAgent → ControllerStreaming request failed
dd:container_log_cancelController → AgentCancel a streaming request when its viewer closes
dd:container_log_responseAgent → ControllerLegacy bounded, one-shot container log data
dd:container_delete_requestController → AgentRequest container removal
dd:container_delete_responseAgent → ControllerRemoval 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)

VariablePurposeDrydock counterpart
TOKENShared secret (preferred)Drydock agent secret
DD_AGENT_SECRETDrydock agent secretDrydock agent secret
TOKEN_FILEPath to file containing tokenDrydock DD_AGENT_SECRET_FILE
DD_AGENT_SECRET_FILEPath to file containing tokenDrydock DD_AGENT_SECRET_FILE
TOKEN_HASHArgon2id PHC hash of token (Standard Mode only)n/a
PORTHTTP listen port (default 3000)Drydock agent port
BIND_ADDRESSStandard default 0.0.0.0; Edge operations default 127.0.0.1n/a
TLS_CERT / TLS_KEYTLS certificate/keyDrydock agent certfile/keyfile
CA_CERTCA cert for Edge Mode TLSDrydock agent cafile
TLS_SKIP_VERIFYSkip TLS verification in Edge Moden/a
DRYDOCK_URLEdge Mode controller URL; agent dials out to /api/portwing/wsn/a
AGENT_IDUUID for this agentDrydock registers via hello.agentId
AGENT_NAMEDisplay name (default: hostname)Drydock agent component name
DD_POLL_INTERVALContainer refresh interval in seconds (default 300)n/a
ADAPTERdrydock (default) or genericn/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 fieldDescription
hostPortwing hostname or IP
portPortwing port (default 3000)
secretShared secret — sent as X-Dd-Agent-Secret
cafileCA certificate for TLS verification
certfile / keyfilemTLS certificate pair
DD_AGENT_ALLOW_INSECURE_SECRET=trueAllow 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-stopped

Drydock 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/).

FeatureStatusNotes
GET /api/events SSE streamCompatiblesse.go:48; AgentClient.ts:717
dd:ack on connectCompatiblesse.go:74; AgentClient.ts:1299
dd:ack field setCompatibleAll fields Drydock reads (AgentClient.ts:744–763) are present
dd:ack memoryGb=0CompatibleDrydock treats 0 as valid
dd:container-added SSECompatiblesse.go:174; AgentClient.ts:1303
dd:container-updated SSECompatiblesse.go:187; AgentClient.ts:1303
dd:container-removed SSECompatibleDrydock only reads .id; extra name field is harmless
dd:watcher-snapshot SSECompatibleEmitted after every poll cycle and on SSE connect
GET /api/containers returns []ContainerCompatibleroutes.go:27; AgentClient.ts:519
Container object shapeCompatiblemodel.go; Drydock model/container.js
GET /api/watchers returns []ComponentDescriptorCompatibleDescriptor selects transport=docker-api, execution=controller, and events=portwing
GET /api/watchers/{type}/{name} single descriptorCompatibleroutes.go:handleWatcherGet; AgentClient.ts:1552
GET /api/triggers returns []CompatiblePortwing advertises no remote trigger; Drydock uses its controller-owned update trigger through the Docker proxy
GET /api/containers/{id}/logs plain textCompatibleDrydock accepts both plain text and {"logs":"..."}
DELETE /api/containers/{id} returns 204Compatibleroutes.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}/batchCompatible (501)Same compatibility behavior
GET /api/log/entries returns []Compatibleroutes.go:handleLogEntries; AgentClient.ts:1503
X-Dd-Agent-Secret authenticationCompatibleserver/http.go auth middleware; AgentClient.ts:73
/health unauthenticatedCompatibleroutes.go:handleSimpleHealth; AgentClient.ts:152
Edge Mode WebSocket /api/portwing/wsStable (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+.