Portwing -- Technical Specification

August 1, 2026 · View on GitHub

Lightweight remote Docker agent for the Drydock container monitoring platform.

1. Overview

Portwing is a standalone Go binary that runs on remote Docker hosts, providing Drydock with secure access to the Docker Engine API, container inventory with update metadata, host metrics, interactive exec sessions, and Docker Compose operations. It communicates directly with the Drydock controller.

flowchart LR
    subgraph server ["Your server"]
        DD["Drydock<br/>(controller + UI)"]
    end

    subgraph host ["Remote host (× N)"]
        direction LR
        LO["Portwing<br/>(agent)"]
        SG["sockguard<br/>(socket filter)"]
        DE["Docker Engine"]
        LO -- "DOCKER_SOCKET" --> SG --> DE
    end

    DD -- "HTTP + SSE · X-Dd-Agent-Secret<br/>(controller initiates inbound)" --> LO

The Drydock controller opens an inbound HTTP connection to each remote host's Portwing independently — handshake on /api/containers, then a long-lived SSE stream on /api/events. sockguard is the recommended socket filter between Portwing and the Docker Engine.

Language: Go 1.26+ (module), built with Go 1.26 (CI) Dependencies: gorilla/websocket, google/uuid -- zero Docker SDK dependency (raw HTTP over Unix socket)

2. Connection Modes

2.1 Mode Detection

DRYDOCK_URL set + (TOKEN or AUTHORIZED_KEYS or PRIVATE_KEY_FILE) set  ->  Edge Mode (outbound WebSocket)
Otherwise                                                              ->  Standard Mode (inbound HTTP server)

2.2 Standard Mode

Portwing runs an HTTP(S) server. The Drydock controller connects inbound.

  • Transparent Docker API proxy (all paths forwarded to Docker socket)
  • Dedicated agent endpoints under /_portwing/*
  • Drydock-compatible REST + SSE endpoints under /api/* (Drydock compatibility)
  • TLS 1.2+ with modern AEAD cipher suites

2.3 Edge Mode

Portwing initiates an outbound WebSocket connection to the Drydock controller's edge endpoint (/api/portwing/ws). All communication is multiplexed over this single connection. The endpoint is Ed25519-only and uses the stable portwing/1.0 protocol. Drydock v1.6.0-rc.11+ provides the complete Portwing v0.9.0 controller-owned watcher/update contract; older compatible controllers may still establish the wire connection without those additive feature semantics. Portwing itself remains pre-v1.0.0.

  • Works behind NAT, firewalls, dynamic IPs
  • Auto-reconnect with exponential backoff + jitter
  • Private HTTP operations listener still serves health, readiness, metrics, and unauthenticated Edge audit export for local monitoring

3. WebSocket Protocol

Protocol identifier: portwing/1.0

3.1 Handshake

sequenceDiagram
    participant L as Portwing
    participant D as Drydock controller
    L->>D: WSS CONNECT /api/portwing/ws
    L->>D: hello (Ed25519 signature, caps, docker version)
    Note over D: verify Ed25519 signature, register agent
    D->>L: welcome (poll interval, config)
    L->>D: dd:component_sync (watcher/trigger descriptors)
    L->>D: dd:container_sync (full container inventory)
    L->>D: metrics (initial host metrics)
    Note over L,D: connection established

3.2 Hello Message

{
  "type": "hello",
  "data": {
    "version": "0.9.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": []
  }
}

The unsupported legacy dd:watch and dd:trigger capabilities are deliberately absent. Discovery uses the Docker watcher descriptor (execution: controller) and an empty trigger list; the corresponding wire message types remain reserved for compatibility but are not advertised.

All JSON application messages are wrapped in an Envelope ({"type": ..., "data": ...}; see internal/protocol/messages.go) — the fields above live under data, not at the top level. (WebSocket ping/pong/close control frames are not wrapped.)

The Drydock /api/portwing/ws endpoint requires the Ed25519 fields (pubKeyId, timestamp, nonce, signature) and rejects token-hash hellos with ed25519-required. tokenHash (SHA-256 of the shared token) is only a fallback for non-edge endpoints.

3.3 Message Types

Core:

TypeDirectionPurpose
helloAgent -> ServerAuth + capability exchange
welcomeServer -> AgentConnection accepted
requestServer -> AgentDocker API request (with requestId), including controller-owned watcher/update calls
responseAgent -> ServerDocker API response (correlated by requestId)
streamBidirectionalStreaming data (logs, exec, build)
stream_endBidirectionalEnd of stream
metricsAgent -> ServerHost metrics payload
container_eventAgent -> ServerDocker lifecycle event; used instead of a duplicate controller event stream
ping / pongEitherKeepalive (30s default)
errorEitherError with optional code
exec_startServer -> AgentStart interactive exec session
exec_readyAgent -> ServerExec session attached
exec_inputServer -> AgentTerminal input (base64)
exec_outputAgent -> ServerTerminal output (base64)
exec_resizeServer -> AgentTerminal resize (cols, rows)
exec_endBidirectionalEnd exec session

Drydock-specific (dd: namespace):

TypeDirectionPurpose
dd:container_syncAgent -> ServerFull container inventory with update metadata
dd:container_addedAgent -> ServerNew container discovered
dd:container_updatedAgent -> ServerContainer state/metadata changed
dd:container_removedAgent -> ServerContainer removed
dd:component_syncAgent -> ServerWatcher + trigger component descriptors
dd:watch_requestServer -> AgentReserved legacy remote-watcher message; not advertised by the controller-owned contract
dd:watch_responseAgent -> ServerReserved legacy remote-watcher response
dd:watch_container_requestServer -> AgentReserved legacy single-container watcher message
dd:watch_container_responseAgent -> ServerReserved legacy single-container watcher response
dd:trigger_requestServer -> AgentReserved legacy remote-trigger message; Portwing advertises no trigger
dd:trigger_responseAgent -> ServerReserved legacy remote-trigger response
dd:container_log_requestServer -> AgentRequest container logs (tail, since, until, follow, timestamps)
dd:container_log_responseAgent -> ServerContainer log data (correlated by requestId)
dd:container_delete_requestServer -> AgentRequest container removal
dd:container_delete_responseAgent -> ServerRemoval result (success/error, correlated by requestId)

The dd:container_log_* and dd:container_delete_* pairs each carry an optional requestId that the agent echoes back on the response, so a controller can correlate concurrent requests for the same container instead of matching responses positionally (a controller must read the echo to use it; one matching positionally is unaffected). dd:container_log_response.logs is plain text — Docker's 8-byte stream-frame headers are stripped for a non-TTY container and a TTY container's header-less stream is passed through unchanged — matching the HTTP /logs route. follow is served as a bounded live window (the agent asks the daemon to end the stream a few seconds out, via a Unix-timestamp until) because the response is a single buffered message, not a stream — continuous tailing uses the request/stream/stream_end path against GET /containers/{id}/logs?follow=1.

4. Standard Mode HTTP API

4.1 Agent Endpoints

EndpointMethodAuthDescription
/_portwing/healthGETNo{"status":"healthy"} + Docker connectivity
/_portwing/infoGETYesAgent version, Docker version, mode, uptime, caps
/_portwing/composePOSTYesDocker Compose operations
/_portwing/metricsGETYesPrometheus metrics (build/host/container + agent request series)
/_portwing/auditGETYesRecent audit records (JSON, newest-first)
/_portwing/audit/exportGETYesCursor-based audit records (NDJSON, oldest-first)

Edge mode keeps a local operations listener on BIND_ADDRESS:PORT for /health, /ready, /_portwing/health, /metrics, and /_portwing/audit/export. This is not the controller transport: control traffic still dials Drydock over the outbound WebSocket. The listener does not apply inbound authentication in Edge mode, so it is a private-operations trust boundary and must not be exposed to an untrusted host, cluster, or public network. Audit records can include client addresses, request paths, stack names, and container identifiers.

4.2 Docker API Proxy

/* (all other paths) -> Transparent proxy to Docker Engine API.

  • Streaming detection for /logs, /attach, /exec/*/start, /events, /build, /images/create, /images/push
  • Connection hijacking for interactive exec (Upgrade: tcp)
  • Hop-by-hop header stripping
  • Binary response auto-detection

4.3 Drydock-Compatible Endpoints

EndpointMethodAuthDescription
/api/eventsGETYesSSE stream (dd:ack, dd:container-added/updated/removed)
/api/containersGETYesFull container inventory JSON
/api/containers/:id/logsGETYesContainer logs (demuxed)
/api/containers/:idDELETEYesRemove container
/api/watchersGETYesWatcher component descriptors
/api/watchers/:type/:nameGETYesSingle watcher descriptor (404 if unknown)
/api/triggersGETYesTrigger component descriptors
/api/watchers/:type/:namePOSTYesLegacy watcher route (501; controller owns execution)
/api/watchers/:type/:name/container/:idPOSTYesLegacy single-container route (501)
/api/triggers/:type/:namePOSTYesLegacy trigger route (501; no trigger advertised)
/api/triggers/:type/:name/batchPOSTYesLegacy batch-trigger route (501)
/api/log/entriesGETYesLog entries — returns [] (Drydock AgentClient.getLogEntries() compatibility)
/healthGETNoSimple health check

4.4 Authentication

  • Header: Authorization: Bearer (primary), X-Portwing-Token, X-Dd-Agent-Secret (Drydock compatibility)
  • Comparison: crypto/subtle.ConstantTimeCompare (timing-safe)
  • Rate limiting: 10 failed attempts per IP per minute, 10K IP cap, background cleanup every 5min
  • Token is optional in Standard mode (if not configured, no auth required)

5. Docker Client Architecture

type DockerClient struct {
    socketPath   string
    apiVersion   string          // Negotiated via GET /version (e.g., "v1.47")
    httpClient   *http.Client    // 30s timeout, 100 max idle conns
    streamClient *http.Client    // No timeout, for logs/exec/events
}
  • Transport: net.Dial("unix", socketPath) -- raw HTTP over Unix domain socket
  • No Docker SDK -- direct HTTP requests (~zero dependencies)
  • API version negotiation: Query /version on startup, extract ApiVersion, prefix paths. Fallback: v1.44
  • Socket auto-detection order: /var/run/docker.sock, $HOME/.docker/run/docker.sock, $HOME/.orbstack/run/docker.sock, /run/docker.sock

6. Docker Compose Operations

Auto-detects docker compose (v2) vs docker-compose (v1).

6.1 Supported Operations

OperationFlags
up-d --remove-orphans, optional --build, --force-recreate, --no-deps {service}
down--remove-orphans, optional --volumes
pull--
ps--format json
logs--tail N
restart / stop / start--

6.2 Security

  • Path traversal protection: All file paths resolved to absolute, verified within stack directory
  • Env var validation: Keys must match ^[a-zA-Z_][a-zA-Z0-9_]*$
  • Env var denylist: LD_PRELOAD, LD_LIBRARY_PATH, PATH, DOCKER_HOST, DOCKER_CONFIG, DOCKER_CERT_PATH, DOCKER_TLS_VERIFY, DOCKER_CONTEXT, HOME, SHELL, BASH_ENV, ENV, CDPATH, IFS
  • Service name validation: Reject values starting with - (flag injection prevention)
  • Registry auth: docker login --password-stdin before up/pull
  • API version forwarding: Sets DOCKER_API_VERSION + DOCKER_HOST in subprocess env

7. Exec / Terminal Sessions

7.1 Edge Mode (WebSocket)

sequenceDiagram
    participant D as Drydock controller
    participant L as Portwing
    participant E as Docker Engine
    D->>L: exec_start {execId, containerId, cmd, user, cols, rows, tty?}
    L->>E: POST /containers/{id}/exec
    L->>E: POST /exec/{id}/start (hijack → 101 Switching Protocols)
    L->>D: exec_ready {execId}
    L->>E: POST /exec/{id}/resize?h={rows}&w={cols}
    D->>L: exec_input {execId, data}
    L->>D: exec_output {execId, data}
    Note over D,L: bidirectional, base64-encoded, 4096-byte pooled buffers
    L-->>D: exec_end {execId, reason} (either side may send)

tty in exec_start is optional and defaults to true: omit it (or send true) to allocate a PTY, false to run the exec without one.

7.2 Standard Mode (HTTP Hijack)

  • Detect /exec/*/start requests
  • If client sends Upgrade: websocket or Upgrade: tcp -> hijack connection, bidirectional io.Copy
  • Non-interactive exec -> return output as normal HTTP response

7.3 Limits

  • Max 100 concurrent exec sessions
  • Max 100 concurrent stream sessions
  • Exec body size limit: 10 MB
  • Retry loop for write/resize (up to 10 attempts, 50ms intervals)

8. Metrics Collection

Interval: 30 seconds

In addition to host/container metrics, the Prometheus endpoints (/_portwing/metrics, /metrics) expose agent-level series: portwing_http_requests_total{method,code} (counter), portwing_http_request_duration_seconds (histogram), portwing_http_requests_in_flight (gauge), portwing_auth_failures_total{reason} (counter), portwing_rate_limited_total (counter).

{
  "cpuUsage": 23.5,
  "cpuCores": 4,
  "memoryTotal": 8589934592,
  "memoryUsed": 4294967296,
  "memoryFree": 4294967296,
  "diskTotal": 107374182400,
  "diskUsed": 53687091200,
  "diskFree": 53687091200,
  "networkRxBytes": 1048576,
  "networkTxBytes": 524288,
  "uptime": 86400
}
MetricSourcePlatform
CPU usage/proc/stat (delta-based)Linux
CPU coresruntime.NumCPU()Cross-platform
Memory/proc/meminfoLinux
Disksyscall.Statfs(dockerDataRoot)Cross-platform
Network/proc/net/dev (all non-lo interfaces)Linux
Uptime/proc/uptimeLinux

SKIP_DF_COLLECTION env var disables disk metrics.

9. Container Event Streaming

Subscribes to Docker /events?type=container API.

9.1 Action Whitelist

create, start, stop, die, kill, restart, pause, unpause, destroy, rename, update, oom, health_status

9.2 Mapping to Drydock Events

Docker ActionDrydock Effect
start (new container)dd:container_added
start (known container)Status update in inventory
die / stopStatus update in inventory
destroydd:container_removed

9.3 Reconnection

Dedicated non-pooled Unix socket. Exponential backoff (5s initial, 60s max), resets after 30s of stable connection.

10. Drydock Container Model

10.1 Container Structure

type Container struct {
    ID              string            `json:"id"`
    Name            string            `json:"name"`
    DisplayName     string            `json:"displayName"`
    DisplayIcon     string            `json:"displayIcon,omitempty"`
    Status          string            `json:"status"`
    Watcher         string            `json:"watcher"`
    Agent           string            `json:"agent,omitempty"`
    Image           ContainerImage    `json:"image"`
    Result          *ContainerResult  `json:"result,omitempty"`
    Error           *ContainerError   `json:"error,omitempty"`
    UpdateAvailable bool              `json:"updateAvailable"`
    UpdateKind      ContainerUpdateKind `json:"updateKind"`
    IncludeTags     string            `json:"includeTags,omitempty"`
    ExcludeTags     string            `json:"excludeTags,omitempty"`
    TransformTags   string            `json:"transformTags,omitempty"`
    Labels          map[string]string `json:"labels,omitempty"`
    Details         *RuntimeDetails   `json:"details,omitempty"`
}

Details (the details field) carries per-container runtime information populated from Docker inspect data:

type RuntimeDetails struct {
    Platform string        `json:"platform,omitempty"`
    Command  string        `json:"command,omitempty"`
    Ports    []PortMapping `json:"ports,omitempty"`
    Network  []NetworkInfo `json:"network,omitempty"`
    Volumes  []VolumeInfo  `json:"volumes,omitempty"`
    Env      []EnvVar      `json:"env,omitempty"`
    Created  string        `json:"created,omitempty"`
    Started  string        `json:"started,omitempty"`
    Health   string        `json:"health,omitempty"`
}

type EnvVar struct {
    Key   string `json:"key"`
    Value string `json:"value"`
}

type PortMapping struct {
    Container uint16 `json:"container"`
    Host      uint16 `json:"host,omitempty"`
    Protocol  string `json:"protocol"`
    IP        string `json:"ip,omitempty"`
}

type NetworkInfo struct {
    Name      string `json:"name"`
    IPAddress string `json:"ipAddress,omitempty"`
    Gateway   string `json:"gateway,omitempty"`
}

type VolumeInfo struct {
    Type        string `json:"type"`
    Source      string `json:"source"`
    Destination string `json:"destination"`
    ReadOnly    bool   `json:"readOnly"`
}

env is parsed from Docker's Config.Env (matching Drydock's ContainerRuntimeEnv shape); redacting sensitive values is the Drydock controller's responsibility.

10.2 Label Parsing

LabelPurpose
dd.watchtrue 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
dd.display.iconCustom icon
dd.groupContainer grouping
dd.link.templateCustom link template

10.3 Controller-Owned Watcher and Update Model

Portwing v0.9.0's docker watcher descriptor declares transport=docker-api, execution=controller, and events=portwing.

  1. Drydock v1.6.0-rc.11+ runs its native watcher and update trigger through Portwing's authenticated Docker proxy: HTTP in Standard Mode and correlated request/response messages in Edge Mode.
  2. Portwing reads dd.* labels, reports raw inventory, and emits Docker lifecycle events; events=portwing avoids a duplicate controller event stream.
  3. Edge Mode sends dd:component_sync before dd:container_sync, so Drydock establishes ownership before it ingests raw container state.
  4. Portwing advertises no remote trigger. Trigger component lists stay empty and legacy watcher/trigger POST routes return 501.
  5. Drydock enriches the raw updateAvailable=false / updateKind=unknown inventory after its controller-owned registry check and owns update state.

These fields are additive, so DrydockCompat remains 1.4.0; older controllers can be wire-compatible without providing the full v0.9.0 feature contract.

10.4 SSE Backward Compatibility

Standard mode /api/events SSE stream produces:

data: {"type":"dd:ack","data":{"version":"0.9.0","os":"linux","arch":"amd64",...}}

data: {"type":"dd:container-added","data":{...Container...}}
data: {"type":"dd:container-updated","data":{...Container...}}
data: {"type":"dd:container-removed","data":{"id":"abc123"}}

11. Security Model

11.1 Authentication

LayerMechanism
Standard modeX-Portwing-Token or X-Dd-Agent-Secret header, timing-safe
Edge modeEd25519 signed hello (pubKeyId/timestamp/nonce/signature) when PRIVATE_KEY_FILE is set; the Drydock /api/portwing/ws endpoint requires it and rejects token-hash hellos
Rate limiting10 failures/min/IP, 10K IP cap, 5min cleanup
Token sourceTOKEN env var or TOKEN_FILE

11.2 TLS

SettingValue
Minimum versionTLS 1.2
Cipher suites (1.2)ECDHE+AES-256-GCM, ECDHE+AES-128-GCM, ECDHE+ChaCha20-Poly1305
CurvesX25519, P-256

11.3 Resource Limits

ResourceLimit
WebSocket read16 MB
Response body read100 MB
Exec request body10 MB
Concurrent exec sessions100
Concurrent stream sessions100

12. Configuration

See README.md for the full configuration reference.

13. Reconnection and Keepalive

13.1 Edge Mode Reconnection

Attempt 1: connect -> fail -> wait 1s (+/-25% jitter)
Attempt 2: connect -> fail -> wait 2s (+/-25% jitter)
...
Attempt N: connect -> fail -> wait min(2^N, 60s) (+/-25% jitter)
On success: reset backoff to 1s

A hello rejection (error frame in place of welcome) is classified before reconnecting. Terminal codes — where retrying the same configuration cannot succeed (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 looping forever. Everything else (timing/capacity conditions like timestamp-skew, replay, rate-limited, registry-full, agent-already-connected, and any unrecognized code) is retried with backoff. This code set mirrors the drydock controller and is not itself a versioned wire contract, so an unrecognized code defaults to retry rather than a hard stop.

13.2 Keepalive

  • Agent sends ping every HEARTBEAT_INTERVAL seconds
  • Read deadline: max(2 * HEARTBEAT_INTERVAL, 60s)
  • Missing pong triggers reconnection

13.3 Graceful Shutdown

  • Listens for SIGINT and SIGTERM
  • Closes all exec sessions
  • Sends WebSocket close frame (code 1000, reason "shutdown")
  • HTTP server: Shutdown() with 10s timeout

14. Build and Release

14.1 Targets

  • Binaries: CGO_ENABLED=0, -trimpath, -s -w (stripped)
  • OS/Arch: linux/amd64, linux/arm64, linux/arm/v7, darwin/amd64, darwin/arm64
  • Docker: Multi-arch manifest at ghcr.io/codeswhat/portwing
  • Base images: Wolfi OS (amd64/arm64), Alpine (armv7)

14.2 Docker Image

Chainguard Wolfi OS packages assembled into a FROM scratch image (Alpine on armv7). Minimal OCI image with no package manager in the runtime — packages are installed into a staging rootfs and copied into the final scratch stage, retaining the package database for scanners.

Packages:

  • Wolfi (amd64/arm64): ca-certificates-bundle, busybox, docker-cli, docker-compose, wget
  • Alpine (armv7): ca-certificates, busybox, docker-cli, docker-cli-compose, wget

15. Migration Strategy

  1. Phase 1: Drop-in Standard Mode -- Replace existing Node.js agent with Portwing binary
  2. Phase 2: Edge Mode -- Complete; the stable portwing/1.0 endpoint is production supported, and Drydock v1.6.0-rc.11+ implements the full v0.9.0 controller-owned watcher/update contract
  3. Phase 3: Native WebSocket in Drydock -- Replace AgentClient SSE with WebSocket
  4. Phase 4: Deprecate SSE -- Remove SSE endpoints after one release cycle