-
+
@@ -34,75 +34,53 @@
> [!WARNING]
> **Pre-1.0 software — APIs may still change.** Portwing is pre-`v1.0.0` (currently `v0.9.23`). The compatibility guarantees that already apply are published in [STABILITY.md](STABILITY.md); other surfaces may still change between minor releases. Pin to an exact version and review the [CHANGELOG](CHANGELOG.md) before upgrading.
+
+
+> [!NOTE]
+> **v0.9.23 is the current release.** It rejects requests carrying a foreign `Origin` header, and a foreign `Host` when authentication is off; `ALLOWED_ORIGINS` and `ALLOWED_HOSTS` list the exceptions. `exec_start` audit records now say whether the exec was admitted. Binary request uploads require a controller that implements the negotiated `edge-request-body-stream` capability. Wire compatibility remains `portwing/1.0` and `DrydockCompat` 1.4.0; full watcher/update feature compatibility requires Drydock `v1.6.0-rc.11+`. See [CHANGELOG.md](CHANGELOG.md) for the full itemized history.
+
Contents
- [Documentation](#documentation)
- [Quick Start](#quick-start)
-- [Native Packages](#native-packages)
-- [Recent Updates](#recent-updates)
+- [Why Portwing](#why-portwing)
- [Features](#features)
-- [Authentication](#authentication)
-- [Connection Modes](#connection-modes)
-- [Standalone Mode](#standalone-generic-mode)
-- [Configuration](#configuration)
-- [API Reference](#api-reference)
-- [Stability Policy](#stability-policy)
-- [Competitive Landscape](#competitive-landscape)
-- [Token Security](#token-security)
-- [Verify a Release](#verify-a-release)
-- [Security](#security)
-- [Audit Logging](#audit-logging)
+- [Feature Comparison](#feature-comparison)
+- [Roadmap](#roadmap)
- [Star History](#star-history)
- [Built With](#built-with)
-- [Community & Support](#community--support)
+- [Community & Support](#community-support)
+- [CodesWhat Ecosystem](#codeswhat-ecosystem)
-> [!NOTE]
-> **v0.9.23 is the current release.** This patch rebuilds ARMv7's Compose with containerd 2.3.6 for GHSA-pg57-6jwg-q645. Agent behavior and configuration are unchanged. Binary request uploads require a controller that implements the negotiated `edge-request-body-stream` capability. Wire compatibility remains `portwing/1.0` and `DrydockCompat` 1.4.0; full watcher/update feature compatibility requires Drydock `v1.6.0-rc.11+`. See [CHANGELOG.md](CHANGELOG.md) for the full itemized history.
-
-```mermaid
-flowchart LR
- subgraph server ["Your server"]
- DD["Drydock (controller + UI)"]
- end
-
- subgraph hostA ["Remote host A"]
- direction LR
- LA["Portwing (agent)"]
- SGA["sockguard (socket filter)"]
- DA["Docker Engine"]
- LA -- "filtered socket" --> SGA --> DA
- end
-
- subgraph hostB ["Remote host B"]
- direction LR
- LB["Portwing (agent)"]
- SGB["sockguard (socket filter)"]
- DB["Docker Engine"]
- LB -- "filtered socket" --> SGB --> DB
- end
-
- DD -- "HTTPS + SSE · X-Dd-Agent-Secret" --> LA
- DD -- "HTTPS + SSE · X-Dd-Agent-Secret" --> LB
-```
-
-> The Drydock controller connects **inbound** to each standard-mode Portwing agent over HTTP/HTTPS (it initiates; Portwing serves). Each agent reaches the Docker Engine only through a sockguard socket filter. In production-supported **edge mode**, the agent instead dials Drydock over the stable `portwing/1.0` WebSocket tunnel, so no inbound control port needs publishing. Full v0.9 watcher/update integration requires Drydock `v1.6.0-rc.11+`. Keep the separate unauthenticated operations listener private — see [Connection Modes](#connection-modes).
-
-### Recommended deployment (hardened)
-
-The strongest posture combines three controls: **sockguard** (socket-level request filtering so Portwing never touches the raw Docker socket directly), **Ed25519 authentication** (signed requests or the required signed edge hello, with replay protection and no shared secret), and a **hardened container runtime** (`read_only`, `cap_drop: ALL`, `no-new-privileges`, secrets-mounted credentials). The plaintext examples publish port 3000 only on host loopback. For remote access, either configure Portwing TLS before widening that bind, or keep the plaintext listener private behind a TLS-terminating reverse proxy. Use production-supported [edge mode](#connection-modes) when the host must dial out. Use Drydock `v1.6.0-rc.11+` for the complete v0.9 watcher/update contract.
+**Recommended: the hardened deployment.** It combines three controls: **sockguard** (socket-level request filtering so Portwing never touches the raw Docker socket), **Ed25519 authentication** (signed requests or the required signed edge hello, with replay protection and no shared secret), and a **hardened container runtime** (`read_only`, `cap_drop: ALL`, `no-new-privileges`, secrets-mounted credentials). The plaintext examples publish port 3000 only on host loopback. For remote access, either configure Portwing TLS before widening that bind, or keep the plaintext listener private behind a TLS-terminating reverse proxy. Use [edge mode](https://portwing.codeswhat.com/docs/connection-modes) when the host must dial out, and Drydock `v1.6.0-rc.11+` for the complete v0.9 watcher/update contract.
**Step 1 — generate a token and pull the example:**
@@ -138,29 +114,10 @@ curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/s
docker compose -f docker-compose.with-sockguard.yml up -d
```
-
Native Packages
-
-Stable releases also ship a Homebrew cask plus signed/checksummed `deb` and
-`rpm` packages for `amd64`, `arm64`, and `armv7`.
-
-```bash
-# macOS
-brew install --cask codeswhat/tap/portwing
-
-# Debian/Ubuntu (after downloading the matching release asset)
-sudo apt install ./portwing_0.9.23_linux_amd64.deb
-
-# Fedora/RHEL (after downloading the matching release asset)
-sudo rpm --install ./portwing_0.9.23_linux_amd64.rpm
-```
-
-Packages install the command and, on Linux, a hardened `portwing.service`; they
-do not start it before authentication is configured. See the
-[native installation guide](https://portwing.codeswhat.com/docs/installation)
-for artifact verification, configuration, upgrade, uninstall, and service-user
-expectations.
+This runs sockguard and Portwing as separate containers sharing a filtered socket volume. Neither container has the raw Docker socket mounted directly; sockguard enforces an allowlist of Docker API operations at the socket level.
-This runs sockguard and Portwing as separate containers sharing a filtered socket volume. Neither container has the raw Docker socket mounted directly; sockguard enforces an allowlist of Docker API operations at the socket level. The full compose file (`examples/docker-compose.with-sockguard.yml`):
+
+Full compose file (examples/docker-compose.with-sockguard.yml)
```yaml
# Portwing + sockguard — two-layer defense.
@@ -235,9 +192,11 @@ volumes:
portwing-stacks:
```
-By design, the `sockguard.yaml` preset above (a copy of sockguard's `portwing.yaml`) denies all exec — `POST /containers/*/exec` matches no allow rule and hits the catch-all deny, returning a 403 with a `not allowed by portwing preset` reason. If Drydock's edge exec feature is in play, use [`examples/docker-compose.edge-with-exec.yml`](examples/docker-compose.edge-with-exec.yml) instead, which pairs edge mode with sockguard's `portwing-with-exec.yaml` preset (`examples/sockguard-with-exec.yaml`) so exec is actually allowed through the proxy; under that preset, policy denials carry specific reasons instead, like `exec denied: privileged exec is not allowed`. Either way, the denial reason now reaches Portwing's exec error and is forwarded in the `exec_end` frame's reason to the controller (drydock-side display of it is still pending). Note sockguard only includes the detailed reason in its `reason` field when `deny_verbosity: verbose` is set in the preset; under the default `minimal` verbosity the surfaced message is just the generic `request denied by sockguard policy`.
+The `sockguard.yaml` preset above (a copy of sockguard's `portwing.yaml`) denies all exec. If Drydock's edge exec feature is in play, use [`examples/docker-compose.edge-with-exec.yml`](examples/docker-compose.edge-with-exec.yml) instead, which pairs edge mode with sockguard's `portwing-with-exec.yaml` preset (`examples/sockguard-with-exec.yaml`). See the [Drydock integration](docs/drydock-integration.md) notes for how denial reasons reach the controller.
-**Upgrade to Ed25519 key auth (zero shared secrets):** generate a keypair with `portwing keygen`, mount the `authorized_keys` file, and set `AUTHORIZED_KEYS=/etc/portwing/authorized_keys` — see [Authentication](#authentication). Use `PRIVATE_KEY_FILE` for signed edge-mode hellos.
+**Upgrade to Ed25519 key auth (zero shared secrets):** generate a keypair with `portwing keygen`, mount the `authorized_keys` file, and set `AUTHORIZED_KEYS=/etc/portwing/authorized_keys`. Use `PRIVATE_KEY_FILE` for signed edge-mode hellos. See [Authentication](https://portwing.codeswhat.com/docs/authentication).
+
+Edge mode variant (outbound WebSocket — stable portwing/1.0)
@@ -285,6 +244,31 @@ volumes:
+
+Native packages (Homebrew, deb, rpm)
+
+Stable releases also ship a Homebrew cask plus signed/checksummed `deb` and
+`rpm` packages for `amd64`, `arm64`, and `armv7`.
+
+```bash
+# macOS
+brew install --cask codeswhat/tap/portwing
+
+# Debian/Ubuntu (after downloading the matching release asset)
+sudo apt install ./portwing_0.9.23_linux_amd64.deb
+
+# Fedora/RHEL (after downloading the matching release asset)
+sudo rpm --install ./portwing_0.9.23_linux_amd64.rpm
+```
+
+Packages install the command and, on Linux, a hardened `portwing.service`; they
+do not start it before authentication is configured. See the
+[native installation guide](https://portwing.codeswhat.com/docs/installation)
+for artifact verification, configuration, upgrade, uninstall, and service-user
+expectations.
+
+
+
Quick start (evaluation only — not for production)
@@ -324,742 +308,186 @@ changing the bind for remote access.
-
-
-
Recent Updates
-
-
-Early release highlights (v0.1.0 – v0.3.0)
-
-For v0.4.0 and later — including v0.9.23, the current release — see [CHANGELOG.md](CHANGELOG.md) for the full itemized history.
-
-- **v0.3.0** — startup banner, Lookout→Portwing rename completed, GoReleaser `dockers_v2` migration, and two edge-mode bug fixes (reconnect backoff reset, steady-state read deadline).
-- **v0.2.0** — the security & observability release. Ed25519 per-request authentication with signed requests via `X-Portwing-Key-ID` / `X-Portwing-Timestamp` / `X-Portwing-Nonce` / `X-Portwing-Signature` headers, verified against an `authorized_keys` file. Replay protection via nonce LRU and timestamp window, SIGHUP hot-reload of the key file, `portwing keygen` CLI subcommand, and `X-Portwing-Reason` diagnostic header on 401s. Signed edge-mode hello via `PRIVATE_KEY_FILE`. Also shipped in v0.2.0:
- - **Key enrollment** — optional single-use `ENROLLMENT_TOKEN` (`POST /api/portwing/enroll`) for bootstrapping the first Ed25519 key — burned on first use, rate-limited, and audit-logged.
- - **Argon2id token hashing** — `TOKEN_HASH` / `TOKEN_HASH_FILE` with OWASP-recommended parameters; SHA-256 success cache keeps per-request cost flat.
- - **MCP server** — read-only Model Context Protocol endpoint at `/_portwing/mcp` (Streamable HTTP, protocol 2025-11-25) for AI assistants (Claude, Cursor, Windsurf). Tools: `list_containers`, `inspect_container`, `container_logs`, `host_metrics`, `container_stats`.
- - **Prometheus metrics** — `/metrics` and `/_portwing/metrics` exposing `portwing_build_info`, container count, and host resource metrics. (The per-request HTTP series — `portwing_http_requests_total`, the `portwing_http_request_duration_seconds` histogram, `portwing_http_requests_in_flight`, `portwing_auth_failures_total`, and `portwing_rate_limited_total` — were added later, in v0.5.0.)
- - **Structured audit logging** — `AUDIT_LOG` env var records auth events, Compose operations, and exec sessions as JSON lines.
- - **Generic REST adapter** — headless REST + SSE management API for standalone mode without a Drydock platform connection (`ADAPTER=generic`).
-- **v0.1.0** — initial release: transparent Docker API proxy, Edge mode WebSocket tunnel, Drydock adapter, SSE event stream, token auth, rate limiting, multi-arch image.
-- **Cross-cutting: CI & supply-chain hardening** (v0.2.0 onward, deepened through v0.5.0) — SHA-pinned actions, five Go fuzz targets (60s CI / 5m nightly), integration suite against a real Docker daemon, weekly vulnerability scans (govulncheck/grype/gosec), monthly mutation testing, OpenSSF Scorecard, CodeQL, cosign keyless signing, per-archive CycloneDX SBOMs, an image SBOM attestation, and SLSA Build L2 provenance on every release.
-
-See [CHANGELOG.md](CHANGELOG.md) for the full itemized history.
-
-
-
-
+Every release image is cosign-signed. Verify the signature before running Portwing in production: see [Verifying Releases](https://portwing.codeswhat.com/docs/verification). Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
-
Features
-
-| Feature | Description |
-|---|---|
-| **Connection Modes** | Standard mode lets Drydock connect inbound over HTTP/SSE. Production-supported edge mode lets the agent dial outbound over the stable `portwing/1.0` WebSocket tunnel for NAT/firewalled hosts; full v0.9 watcher/update support requires Drydock `v1.6.0-rc.11+`. |
-| **Transparent Docker API Proxy** | All Docker Engine API paths forwarded to the local daemon — streaming endpoints, exec session hijacking, and long-lived connections included. |
-| **Ed25519 Per-Client Authentication** | Per-request signatures with per-client keys, replay protection via nonce LRU and timestamp window, `authorized_keys`-style rotation via SIGHUP, zero shared secrets. |
-| **Argon2id Token Hashing** | Hash your token at rest with OWASP-recommended Argon2id parameters; `TOKEN_HASH_FILE` for Docker secrets support; SHA-256 success cache keeps per-request overhead flat. |
-| **MCP Server** | AI assistants connect to `/_portwing/mcp` (Streamable HTTP, protocol revisions 2026-07-28 and 2025-11-25). Read-only tools: `list_containers`, `inspect_container`, `container_logs`, `host_metrics`, `container_stats`. Env variable values are never transmitted. |
-| **Container Inventory** | Full container metadata with `dd.*` label parsing and SSE broadcasting. Portwing marks watcher execution as controller-owned so compatible Drydock runs native watcher/update calls through the Standard or Edge Docker proxy. |
-| **Prometheus Metrics** | Host and per-container CPU/memory/network in cAdvisor-compatible format at `/_portwing/metrics`. Zero external dependencies. |
-| **Audit Logging** | Structured JSON of every API call, auth event, exec session, and Compose operation. Recent records are retained in memory by default; file/stdout/stderr persistence is opt-in. |
-| **Host Metrics** | CPU, memory, disk, network, and uptime collection. |
-| **Interactive Exec** | Terminal sessions via WebSocket or HTTP hijack with 100 concurrent session cap. |
-| **Docker Compose** | Full lifecycle management with security hardening — path traversal protection, env var denylist, service name injection prevention. |
-| **SSE Compatibility** | Drop-in replacement for existing Drydock agents, including `dd:watcher-snapshot` full inventory on connect. |
-| **Signed Supply Chain** | Cosign keyless signatures, per-archive CycloneDX SBOMs, an image SBOM attestation, and SLSA Build L2 provenance on every release. Verifiable without managing signing keys. |
-| **Two-Layer Defense** | Pair with [sockguard](https://github.com/codeswhat/sockguard) so the agent never touches the raw Docker socket directly. |
-| **Minimal Footprint** | Static Go binary, ~47 MB Wolfi (Chainguard) container image. CGO disabled, stripped, no external runtime dependencies. |
-| **Standalone Mode** | `ADAPTER=generic` provides a clean REST + SSE API on `/api/v1/*` backed by the local Docker daemon — no Drydock account required. |
+See the [Getting Started guide](https://portwing.codeswhat.com/docs/getting-started) for Docker Compose, TLS, and Sockguard variants. What changed in each release is in [`CHANGELOG.md`](CHANGELOG.md) and on the [GitHub Releases](https://github.com/CodesWhat/portwing/releases) page.
-
Authentication
+
Why Portwing
-
-Token Authentication (quickstart)
+Controlling a remote Docker host usually means exposing the Docker socket, or running an agent that mounts it directly. Portwing is a small static Go agent that sits in front of the daemon instead. It is a transparent Docker API proxy with per-client Ed25519 authentication, fail-closed startup, structured audit logging, and signed releases, and it pairs with sockguard so the agent never touches the raw Docker socket.
-Set `TOKEN` to a random secret. All requests must supply it via
-`Authorization: Bearer`, `X-Portwing-Token`, or `X-Dd-Agent-Secret`.
-
-```bash
-TOKEN=$(openssl rand -hex 32)
-docker run -d --name portwing \
- --group-add $(stat -c '%g' /var/run/docker.sock) \
- -v /var/run/docker.sock:/var/run/docker.sock \
- -e TOKEN="$TOKEN" \
- -p 127.0.0.1:3000:3000 \
- ghcr.io/codeswhat/portwing:0.9.23
-```
-
-
-
-
-Ed25519 Per-Client Key Authentication (recommended)
-
-Ed25519 keypairs give per-client identity with per-request signatures and
-replay protection. No shared secrets.
-
-**Generate a keypair:**
-
-```bash
-# Writes the private key (PEM PKCS#8) and the authorized_keys line to stdout.
-portwing keygen -comment "my-platform:prod"
-```
-
-**Copy the `authorized_keys` line to the agent host:**
-
-```text
-# /etc/portwing/authorized_keys (root:65532, mode 0640 — readable by the
-# container's non-root user, not world-readable)
-ed25519 AAAA... my-platform:prod
-```
+It works for Drydock, which connects inbound to a standard-mode agent or accepts an outbound edge-mode tunnel from hosts behind NAT, and it runs standalone with a REST + SSE API when no controller is involved.
-**Start the agent with Ed25519 auth:**
-
-```bash
-docker run -d --name portwing \
- --group-add $(stat -c '%g' /var/run/docker.sock) \
- -v /var/run/docker.sock:/var/run/docker.sock \
- -v /etc/portwing/authorized_keys:/etc/portwing/authorized_keys:ro \
- -e AUTHORIZED_KEYS=/etc/portwing/authorized_keys \
- -p 127.0.0.1:3000:3000 \
- ghcr.io/codeswhat/portwing:0.9.23
-```
-
-**Key rotation (zero-downtime):**
-
-1. Generate a new keypair: `portwing keygen -comment "my-platform:prod:2026-07"`
-2. Append the new public key line to the authorized_keys file on the agent host.
-3. Send `SIGHUP` to reload: `kill -HUP $(pidof portwing)` or
- `docker kill --signal HUP portwing`. Both old and new keys are now active.
-4. Update the platform to use the new private key.
-5. Remove the old key from the file and send another `SIGHUP`.
-
-Token auth (`TOKEN`/`TOKEN_HASH`) continues to work alongside Ed25519 — both
-can be set simultaneously during migration. The middleware checks for
-`X-Portwing-Signature` first; if absent, it falls back to the token check.
-HTTP signature version 2 also requires
-`X-Portwing-Signature-Version: 2` and signs the complete request target,
-including the escaped path and exact raw query string. Legacy unversioned
-signatures are accepted only on query-free requests.
-
-
-
-
-
-
Connection Modes
-
-
-Standard Mode and Edge Mode
-
-### Standard Mode — implemented
-
-Portwing runs an HTTP(S) server; the **Drydock controller connects inbound** and pulls from it. This is the integration that works today.
-
-- Set when `DRYDOCK_URL` is not configured
-- Drydock authenticates with the `X-Dd-Agent-Secret` shared secret (optional mTLS)
-- Handshake on `GET /api/containers` · `/api/watchers` · `/api/triggers`, then a long-lived **SSE** stream on `GET /api/events`
-- Transparent Docker API proxy on all paths; agent endpoints under `/_portwing/*`
-- Optional TLS with modern cipher suites (TLS 1.2+)
-
-### Edge Mode — production supported
-
-Portwing initiates an outbound WebSocket to the controller's edge endpoint (`DRYDOCK_URL` + `/api/portwing/ws`) for hosts with no inbound control port. The stable `portwing/1.0` path is covered by Drydock's cross-repo `quality-portwing-fleet-soak.yml` workflow, including real Portwing processes, concurrent exec, continuous logs, reconnect storms, and controller backpressure. Portwing's own `quality-soak-weekly.yml` is a separate Standard/generic HTTP and SSE RSS-growth soak. Drydock `v1.6.0-rc.11+` provides full v0.9 watcher/update feature compatibility; older compatible controllers can establish the wire connection but lack that coordinated execution model. The endpoint is **Ed25519-only**: set `PRIVATE_KEY_FILE` and register the public key with Drydock.
-
-- Set when `DRYDOCK_URL` is configured along with `PRIVATE_KEY_FILE` — mandatory, not optional; Drydock rejects token-only agents, so `TOKEN` or `AUTHORIZED_KEYS` alone are not sufficient
-- Targets hosts behind NAT, firewalls, and dynamic IPs
-- Auto-reconnect with exponential backoff + jitter; signed hello via `PRIVATE_KEY_FILE`
-- Sends watcher component ownership before raw inventory; controller-owned
- Docker calls use correlated WebSocket `request`/`response` messages
-
-Edge mode's controller trust is **TLS-only and one-directional**: the signed
-hello lets the controller verify Portwing, but nothing verifies the
-controller's identity in return, so a plaintext `http://`/`ws://`
-`DRYDOCK_URL` would let an on-path attacker complete the handshake and drive
-dockerd. Portwing fails closed on a plaintext `DRYDOCK_URL` unless you set
-`ALLOW_INSECURE_EDGE_URL=true`, which is for trusted local testing only —
-always use `https://`/`wss://` against a real controller.
-
-```text
-DRYDOCK_URL set + PRIVATE_KEY_FILE set → Edge Mode (outbound WebSocket)
-DRYDOCK_URL unset → Standard Mode (inbound HTTP server)
-```
-
-`DRYDOCK_URL` set without `PRIVATE_KEY_FILE` is neither of the above — it's a
-fatal startup error, since the edge endpoint is Ed25519-only and rejects
-token-only agents.
-
-
-
-
-
-
Standalone (Generic) Mode
-
-
-Run without a Drydock platform connection
-
-Run Portwing without any external controller by setting `ADAPTER=generic`.
-You get a clean REST + SSE API on `/api/v1/*` backed directly by the local
-Docker daemon — no Drydock account required.
-
-```bash
-docker run -d \
- --name portwing \
- --group-add $(stat -c '%g' /var/run/docker.sock) \
- -v /var/run/docker.sock:/var/run/docker.sock \
- -e ADAPTER=generic \
- -e TOKEN=my-secret \
- -p 127.0.0.1:3000:3000 \
- ghcr.io/codeswhat/portwing:0.9.23
-```
-
-### Endpoints
-
-| Endpoint | Description |
-|----------|-------------|
-| `GET /api/v1/version` | Agent version, protocol info |
-| `GET /api/v1/containers` | Cached container inventory |
-| `GET /api/v1/containers/{id}/logs` | Container logs (`tail`, `since`, `until`, `follow`) |
-| `GET /api/v1/events` | SSE stream of Docker lifecycle events |
-
-### curl examples
-
-```bash
-TOKEN=my-secret
-
-# Agent version
-curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/version | jq .
-
-# Container inventory
-curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/containers | jq .
-
-# Last 50 log lines from a container
-curl -s -H "Authorization: Bearer $TOKEN" \
- "http://localhost:3000/api/v1/containers/my-container/logs?tail=50"
-
-# Stream container logs live
-curl -sN -H "Authorization: Bearer $TOKEN" \
- "http://localhost:3000/api/v1/containers/my-container/logs?follow=1"
-
-# Stream Docker lifecycle events (SSE)
-curl -sN -H "Authorization: Bearer $TOKEN" \
- http://localhost:3000/api/v1/events
-```
-
-Each SSE event is a JSON object:
-
-```json
-{
- "ts": "2026-06-11T10:00:00Z",
- "type": "container",
- "action": "start",
- "containerId": "abc123def456",
- "name": "my-container",
- "image": "nginx:latest",
- "labels": { "app": "web" }
-}
-```
-
-A comment heartbeat line (`: heartbeat`) is written every 30 seconds to keep
-the connection alive through proxies.
-
-
-
-
-
-
Configuration
-
-
-Environment variable reference
-
-### Connection
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `DRYDOCK_URL` | -- | WebSocket URL for Edge mode (`wss://...`). A plaintext `http://`/`ws://` scheme is rejected unless `ALLOW_INSECURE_EDGE_URL=true` — see below. |
-| `TOKEN` | -- | Authentication token (plaintext) |
-| `TOKEN_FILE` | -- | Path to file containing token |
-| `TOKEN_HASH` | -- | Argon2id hash of token (generate with `portwing hash-token`) |
-| `TOKEN_HASH_FILE` | -- | Path to file containing Argon2id hash |
-| `AUTHORIZED_KEYS` | -- | Path to Ed25519 authorized_keys file (per-client asymmetric auth) |
-| `AUTHORIZED_KEYS_FILE` | -- | Alias for `AUTHORIZED_KEYS` |
-| `MAX_CLOCK_SKEW_SECONDS` | `60` | Maximum allowed clock skew for Ed25519 request timestamps |
-| `NONCE_LRU_SIZE` | `10000` | In-memory nonce cache capacity for replay protection |
-| `ENROLLMENT_TOKEN` | -- | One-shot bootstrap token for Model C key enrollment |
-| `ENROLLMENT_TOKEN_FILE` | -- | File containing enrollment token |
-| `PRIVATE_KEY_FILE` | -- | Ed25519 private key (PEM PKCS#8) for signing edge-mode hello |
-| `CA_CERT` | -- | Custom CA certificate for Edge mode |
-| `TLS_SKIP_VERIFY` | `false` | Skip TLS verification (testing only) |
-| `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` | -- | Standard proxy variables. Edge mode sends its outbound controller connection through the proxy they select; `NO_PROXY` hosts connect directly. Loopback controllers are never proxied. The proxy URL must be `http://` or `socks5://` |
-| `PORT` | `3000` | HTTP server port |
-| `BIND_ADDRESS` | Standard: `0.0.0.0`; Edge: `127.0.0.1` | HTTP/operations listener bind address. Edge mode refuses a non-loopback address unless `ALLOW_UNAUTHENTICATED_REMOTE=true` — the operations listener carries no authentication, so set it only on an isolated monitoring network. |
-| `TLS_CERT` | -- | Server TLS certificate (Standard mode) |
-| `TLS_KEY` | -- | Server TLS key (Standard mode) |
-| `TRUSTED_PROXIES` | -- | Comma-separated CIDRs of reverse proxies whose `X-Forwarded-For` is trusted; unset means forwarding headers are ignored |
-| `ALLOWED_ORIGINS` | -- | Comma-separated exact browser origins (`scheme://host[:port]`) Portwing accepts; unset means any request carrying an `Origin` header gets 403 before authentication, and requests without one are unaffected. It doesn't enable cross-origin fetch: Portwing sends no CORS headers |
-| `ALLOWED_HOSTS` | -- | Comma-separated hostnames accepted in the `Host` header while authentication is off and on edge mode's operations listener; IPs, `localhost` and single-label names are always accepted. A dotted hostname with no auth needs listing here |
-| `ALLOW_UNAUTHENTICATED` | `false` | Explicit local-development opt-in when no credential is configured; otherwise startup fails closed |
-| `ALLOW_UNAUTHENTICATED_REMOTE` | `false` | Additional dangerous opt-in required for unauthenticated non-loopback binds |
-| `ALLOW_INSECURE_EDGE_URL` | `false` | Dangerous opt-in to connect Edge mode to a plaintext `http://`/`ws://` `DRYDOCK_URL`; the controller's identity is otherwise verified by TLS alone, so this is for trusted local testing only |
-
-`TLS_CERT` and `TLS_KEY` must be configured together. Setting only one is a
-startup error.
-
-### Docker
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `DOCKER_SOCKET` | Auto-detect | Docker socket path |
-| `STACKS_DIR` | `/data/stacks` | Compose stack file directory |
-
-Portwing talks to the Docker daemon over the Unix socket only — there is no `DOCKER_HOST` TCP transport.
-
-### Agent Identity
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `AGENT_ID` | UUID v4 | Unique agent identifier |
-| `AGENT_NAME` | Hostname | Human-readable name |
-
-### Operational
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `HEARTBEAT_INTERVAL` | `30` | Ping interval (seconds) |
-| `WELCOME_TIMEOUT` | `30` | Seconds to await the Drydock welcome message in edge mode |
-| `REQUEST_TIMEOUT` | `30` | Docker API request timeout (seconds) |
-| `RECONNECT_DELAY` | `1` | Initial reconnect delay (seconds) |
-| `MAX_RECONNECT_DELAY` | `60` | Max reconnect delay (seconds) |
-| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
-| `SKIP_DF_COLLECTION` | -- | Disable disk metrics |
-| `MAX_STREAM_SESSIONS` | `100` | Standard mode: max concurrent streaming proxy responses (SPEC 7.3); non-positive disables the bound. Edge mode's equivalent limit is fixed, not configurable. |
-| `MAX_EXEC_SESSIONS` | `100` | Standard mode: max concurrent hijacked exec/attach sessions (SPEC 7.3); non-positive disables the bound. Edge mode's equivalent limit is fixed, not configurable. |
-| `AUDIT_LOG` | -- | Optional persistent audit sink: `stdout`, `stderr`, or a file path; unset disables only the sink (the in-memory ring is controlled by `AUDIT_BUFFER_SIZE`) |
-| `AUDIT_BUFFER_SIZE` | `256` | In-memory audit records retained for `GET /_portwing/audit`; `0` disables. Independent of `AUDIT_LOG`. |
-
-### Adapter
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `ADAPTER` | `drydock` | Adapter to use: `drydock` (Drydock-compatible) or `generic` (standalone REST/SSE) |
-
-### Drydock Compatibility
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `DD_AGENT_SECRET` | -- | Drydock agent secret token |
-| `DD_AGENT_SECRET_FILE` | -- | Drydock agent secret token file |
-| `DD_POLL_INTERVAL` | `300` | Container inventory refresh (seconds) |
-
-
-
-
-
-
API Reference
-
-
-Health, agent, MCP, Drydock-compatible, and proxy endpoints
-
-### Health Endpoints
-
-| Endpoint | Method | Auth | Description |
-|----------|--------|------|-------------|
-| `/health` | GET | No | Process liveness; never probes dependencies |
-| `/ready` | GET | No | Docker readiness, plus controller readiness in edge mode |
-| `/_portwing/health` | GET | No | Compatibility alias for `/ready` |
-
-Every response includes status, live/ready booleans, mode, version, uptime,
-Docker state, and edge-controller state. `/ready` returns HTTP 503 when a
-required dependency is unreachable. The image healthcheck uses `/health`;
-Kubernetes examples use `/health` for liveness and `/ready` for readiness.
-
-### Agent Endpoints
-
-| Endpoint | Method | Auth | Description |
-|----------|--------|------|-------------|
-| `/_portwing/info` | GET | Yes | Agent version, mode, capabilities |
-| `/_portwing/compose` | POST | Yes | Docker Compose operations |
-| `/_portwing/metrics` | GET | Yes | Prometheus metrics (agent-scoped) |
-| `/metrics` | GET | Yes | Prometheus metrics (Drydock agent secret) |
-| `/_portwing/audit` | GET | Yes | Recent audit records (JSON, newest-first; `?limit=N`) |
-| `/_portwing/audit/export` | GET | Standard: yes; edge: no | Cursor-based NDJSON export (oldest-first) |
-| `/_portwing/mcp` | POST | Yes | MCP server (JSON-RPC 2.0, protocol revisions 2026-07-28 and 2025-11-25) |
-
-In Edge Mode, `/_portwing/audit/export` is served without inbound
-authentication on the same limited operations listener as `/metrics`,
-`/health`, and `/ready`. Audit records can contain client addresses, request
-paths, container identifiers, and operation names. Never publish this listener
-to a host port or untrusted network; restrict it to the local/private
-observability network and allow only the exporter that consumes it.
-
-### MCP — AI Assistant Integration
-
-Portwing exposes a read-only [Model Context Protocol](https://modelcontextprotocol.io/) endpoint
-at `POST /_portwing/mcp`. AI assistants (Claude, Cursor, Windsurf, or any MCP client) can query
-live container state through this endpoint using their standard tool-call flow.
-
-**Protocol:** MCP revisions 2026-07-28 and 2025-11-25 over Streamable HTTP, in stateless
-single-request mode with `Content-Type: application/json`. A request takes the 2026-07-28 path
-when `params._meta` contains `io.modelcontextprotocol/protocolVersion` (any value) or when its
-`MCP-Protocol-Version` header is `2026-07-28`. On that path `server/discover` is available, results
-carry `resultType`, `tools/list` carries `ttlMs` and `cacheScope`, and the `MCP-Protocol-Version`,
-`Mcp-Method` and `Mcp-Name` headers must match the body. A `_meta` version other than 2026-07-28
-is rejected with `-32022` on HTTP 400, and the header alone without that `_meta` key is rejected
-with `-32602`. Every other request gets the 2025-11-25 behaviour, with `initialize` and `ping`,
-unchanged.
-Clients on either revision connect to the same URL with no extra configuration.
-
-**Available tools:**
-
-| Tool | Description |
-|------|-------------|
-| `list_containers` | All containers — id, names, image, state, status, labels |
-| `inspect_container(id)` | State, image, env-var count (values never exposed via this MCP tool), mounts, networks, restart policy |
-| `container_logs(id, tail)` | Last N lines of stdout/stderr (max 500 lines and 256 KiB; `truncated` reports a cut) |
-| `host_metrics` | CPU, memory, disk, network, uptime snapshot (Linux only, see below) |
-| `container_stats(id)` | One-shot CPU/memory/network stats for a container |
-
-Every tool is annotated `readOnlyHint: true`. `openWorldHint` is `false` except on
-`container_logs`, whose text is written by the workload and should be treated as untrusted input.
-
-**Platform support:** `host_metrics` reads everything except the CPU core count and disk from
-`/proc`, so it works on the Linux container, `.deb` and `.rpm` builds and on no other platform. On
-a native macOS install (including the Homebrew cask) the tool returns an MCP error naming the
-missing procfs rather than a snapshot of zeros, and `/metrics` reports
-`portwing_host_metrics_supported 0` with the host resource series omitted. Every other MCP tool
-is unaffected, since they read from the Docker API rather than the host.
-
-Disk usage is measured separately with `statfs` against the Docker daemon's actual data root
-(resolved from `/info`, not assumed to be `/var/lib/docker`), so it can fail independently of the
-`/proc`-backed fields even on Linux — for example when the data root is unreadable. That failure
-is reported in-band rather than as a zero: the response carries `diskMetricsAvailable: false` and
-a `diskError` string, and `/metrics` reports `portwing_host_disk_metrics_available 0` with
-`portwing_host_disk_total_bytes`/`portwing_host_disk_used_bytes` omitted, independent of
-`portwing_host_metrics_supported`.
-
-**Credential hygiene:** `inspect_container` returns only the *count* of environment variables —
-values are never transmitted through this MCP tool, preventing accidental secret leakage. This
-scoping applies only to `inspect_container`: the sibling `GET /api/containers` REST endpoint (and
-the container inventory synced over the edge WebSocket) returns full container env var values by
-design — see [Security Model](docs/security-model.md) for details.
-
-#### Add to Claude Desktop (claude_desktop_config.json)
-
-```json
-{
- "mcpServers": {
- "portwing": {
- "command": "curl",
- "args": ["-s", "-X", "POST",
- "-H", "Content-Type: application/json",
- "-H", "Authorization: Bearer YOUR_PORTWING_TOKEN",
- "http://your-host:3000/_portwing/mcp"],
- "type": "http",
- "url": "http://your-host:3000/_portwing/mcp",
- "headers": {
- "Authorization": "Bearer YOUR_PORTWING_TOKEN"
- }
- }
- }
-}
-```
-
-#### Add via claude mcp add (CLI)
-
-```bash
-claude mcp add --transport http \
- --header "Authorization: Bearer YOUR_PORTWING_TOKEN" \
- portwing http://your-host:3000/_portwing/mcp
-```
-
-#### .mcp.json (project-level, Cursor / Windsurf / any client)
-
-```json
-{
- "mcpServers": {
- "portwing": {
- "type": "http",
- "url": "http://your-host:3000/_portwing/mcp",
- "headers": {
- "Authorization": "Bearer YOUR_PORTWING_TOKEN"
- }
- }
- }
-}
-```
-
-Replace `YOUR_PORTWING_TOKEN` with the value you set in `TOKEN` / `TOKEN_FILE` / `TOKEN_HASH`.
-
-### Drydock-Compatible Endpoints
-
-| Endpoint | Method | Description |
-|----------|--------|-------------|
-| `/api/events` | GET | SSE event stream (`dd:ack`, container events) |
-| `/api/containers` | GET | Container inventory |
-| `/api/containers/:id/logs` | GET | Container logs |
-| `/api/containers/:id` | DELETE | Remove container |
-| `/api/watchers` | GET | Watcher components |
-| `/api/triggers` | GET | Trigger components |
-
-### Docker API Proxy
-
-All other paths (`/*`) are transparently proxied to the Docker Engine API, including streaming endpoints and exec session hijacking.
-
-### Metrics
-
-Portwing exposes Prometheus metrics at `/_portwing/metrics` (and the alias
-`/metrics`). Standard mode requires bearer auth; edge mode exposes `/metrics`
-and `/_portwing/audit/export` without authentication on its limited private
-operations listener. In addition to
-build/host/per-container and HTTP series, the endpoint reports edge-controller
-connection/reconnect/backpressure state plus audit ring, sink, and export
-health.
-
-Prometheus scrape config:
-
-```yaml
-scrape_configs:
- - job_name: portwing
- scheme: https # or http if TLS not configured
- static_configs:
- - targets: ["your-host:3000"]
- authorization:
- type: Bearer
- credentials: YOUR_PORTWING_TOKEN
- tls_config:
- # ca_file: /etc/prometheus/portwing-ca.crt # if using custom CA
- insecure_skip_verify: false
-```
-
-
-
-
-
-
Stability Policy
-
-Portwing `v0.9.x` publishes the compatibility contract that becomes binding at
-`v1.0.0`. It defines semantic-versioning guarantees for the HTTP/OpenAPI
-surface, environment variables, MCP tools and schemas, and the
-`portwing/1.0`/`DrydockCompat` wire protocol, together with the deprecation and
-security-exception process.
-
-Read [STABILITY.md](STABILITY.md) for the policy and
-[COMPATIBILITY.md](COMPATIBILITY.md) for the exact Portwing, Drydock, and
-sockguard versions tested together.
-
-
-
-
Competitive Landscape
-
-The published [competitive landscape](https://portwing.codeswhat.com/docs/competitive-landscape) compares
-Portwing with Portainer Agent, Komodo Periphery, Arcane Agent, Hawser, Docker's
-native remote-access options, and adjacent agents. It records primary sources,
-feature ownership across Portwing/Drydock/Sockguard, pre-v1 gates, candidate
-work, and explicit non-goals. Unknown competitor behavior is recorded as “not
-documented,” not guessed as absent.
-
-
-
-
Token Security
-
-
-Plaintext, file-based, and hash-at-rest token options
-
-### Plaintext token (testing only)
-
-> **Warning:** Environment variables are visible in `docker inspect` and
-> process listings. For production, use `TOKEN_FILE` or `TOKEN_HASH_FILE`
-> with a mounted secret.
-
-```bash
-# Generate a strong token
-TOKEN=$(openssl rand -hex 32)
-docker run -e TOKEN="$TOKEN" ... ghcr.io/codeswhat/portwing:0.9.23
-```
-
-### File-based token (production)
-
-```bash
-TOKEN=$(openssl rand -hex 32)
-printf '%s' "$TOKEN" > /run/secrets/portwing-token
-chown 65532:65532 /run/secrets/portwing-token && chmod 0400 /run/secrets/portwing-token
-docker run -e TOKEN_FILE=/run/secrets/portwing-token \
- -v /run/secrets/portwing-token:/run/secrets/portwing-token:ro \
- ... ghcr.io/codeswhat/portwing:0.9.23
-```
-
-### Hash-at-rest with TOKEN_HASH
-
-Store only an Argon2id hash so the plaintext token never appears in env dumps
-or config files:
-
-```bash
-# Generate the hash (token is read from stdin, never argv)
-HASH=$(printf '%s' "$TOKEN" | portwing hash-token)
-# $argon2id$v=19$m=19456,t=2,p=1$$
+```mermaid
+flowchart LR
+ subgraph server ["Your server"]
+ DD["Drydock (controller + UI)"]
+ end
-# Use the hash instead of the plaintext
-docker run -e TOKEN_HASH="$HASH" ... ghcr.io/codeswhat/portwing:0.9.23
-```
+ subgraph hostA ["Remote host A"]
+ direction LR
+ LA["Portwing (agent)"]
+ SGA["sockguard (socket filter)"]
+ DA["Docker Engine"]
+ LA -- "filtered socket" --> SGA --> DA
+ end
-Or write the hash to a file and use `TOKEN_HASH_FILE`:
+ subgraph hostB ["Remote host B"]
+ direction LR
+ LB["Portwing (agent)"]
+ SGB["sockguard (socket filter)"]
+ DB["Docker Engine"]
+ LB -- "filtered socket" --> SGB --> DB
+ end
-```bash
-printf '%s' "$TOKEN" | portwing hash-token > /run/secrets/portwing-token-hash
-docker run -e TOKEN_HASH_FILE=/run/secrets/portwing-token-hash ...
+ DD -- "HTTPS + SSE · X-Dd-Agent-Secret" --> LA
+ DD -- "HTTPS + SSE · X-Dd-Agent-Secret" --> LB
```
-
+> The Drydock controller connects **inbound** to each standard-mode Portwing agent over HTTP/HTTPS (it initiates; Portwing serves). Each agent reaches the Docker Engine only through a sockguard socket filter. In production-supported **edge mode**, the agent instead dials Drydock over the stable `portwing/1.0` WebSocket tunnel, so no inbound control port needs publishing. Full v0.9 watcher/update integration requires Drydock `v1.6.0-rc.11+`. Keep the separate unauthenticated operations listener private — see [Connection Modes](https://portwing.codeswhat.com/docs/connection-modes).
-
Verify a Release
-
-
-Cosign verification for checksums and container images
-
-Portwing releases are signed with [Sigstore cosign](https://github.com/sigstore/cosign)
-via GitHub Actions keyless signing. Checksums and container images can be
-verified without managing signing keys.
-
-### Verify the checksums file
-
-```bash
-VERSION=0.9.23
-
-cosign verify-blob \
- --certificate-identity "https://github.com/CodesWhat/portwing/.github/workflows/release.yml@refs/tags/v${VERSION}" \
- --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
- --bundle "checksums.txt.bundle" \
- "checksums.txt"
-```
-
-### Verify the container image
-
-```bash
-VERSION=0.9.23
-
-cosign verify \
- --certificate-identity "https://github.com/CodesWhat/portwing/.github/workflows/release.yml@refs/tags/v${VERSION}" \
- --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
- "ghcr.io/codeswhat/portwing:${VERSION}"
-```
-
-### SBOM
-
-Each binary archive has a matching CycloneDX release asset, for example
-`portwing_0.9.23_linux_amd64.tar.gz.cyclonedx.json`. The SBOM has no standalone
-cosign bundle; verify the signed `checksums.txt`, then verify the SBOM's digest
-against that manifest. Public releases also give every checksummed asset its
-own GitHub build-provenance attestation. The container image carries a separate
-OCI SBOM attestation.
+
Features
-
+| | Feature | Description |
+|---|---|---|
+| 🔀 | **Connection Modes** | Standard mode lets Drydock connect inbound over HTTP/SSE. Production-supported edge mode lets the agent dial outbound over the stable `portwing/1.0` WebSocket tunnel for NAT/firewalled hosts; full v0.9 watcher/update support requires Drydock `v1.6.0-rc.11+`. |
+| 🔁 | **Transparent Docker API Proxy** | All Docker Engine API paths forwarded to the local daemon — streaming endpoints, exec session hijacking, and long-lived connections included. |
+| 🔑 | **Ed25519 Per-Client Authentication** | Per-request signatures with per-client keys, replay protection via nonce LRU and timestamp window, `authorized_keys`-style rotation via SIGHUP, zero shared secrets. |
+| 🔒 | **Argon2id Token Hashing** | Hash your token at rest with OWASP-recommended Argon2id parameters; `TOKEN_HASH_FILE` for Docker secrets support; SHA-256 success cache keeps per-request overhead flat. |
+| 🤖 | **MCP Server** | AI assistants connect to `/_portwing/mcp` (Streamable HTTP, protocol revisions 2026-07-28 and 2025-11-25). Read-only tools: `list_containers`, `inspect_container`, `container_logs`, `host_metrics`, `container_stats`. Env variable values are never transmitted. |
+| 📦 | **Container Inventory** | Full container metadata with `dd.*` label parsing and SSE broadcasting. Portwing marks watcher execution as controller-owned so compatible Drydock runs native watcher/update calls through the Standard or Edge Docker proxy. |
+| 📈 | **Prometheus Metrics** | Host and per-container CPU/memory/network in cAdvisor-compatible format at `/_portwing/metrics`. Zero external dependencies. |
+| 📜 | **Audit Logging** | Structured JSON of every authenticated API call, auth event, exec session, and Compose operation. Recent records are retained in memory by default; file/stdout/stderr persistence is opt-in. |
+| 🖥️ | **Host Metrics** | CPU, memory, disk, network, and uptime collection. |
+| ⌨️ | **Interactive Exec** | Terminal sessions via WebSocket or HTTP hijack with a default cap of 100 concurrent sessions. |
+| 🗂️ | **Docker Compose** | Full lifecycle management with security hardening — path traversal protection, env var denylist, service name injection prevention. |
+| 📡 | **SSE Compatibility** | Drop-in replacement for existing Drydock agents, including `dd:watcher-snapshot` full inventory on connect. |
+| ✍️ | **Signed Supply Chain** | Cosign keyless signatures, per-archive CycloneDX SBOMs, an image SBOM attestation, and SLSA Build L2 provenance on every release. Verifiable without managing signing keys. |
+| 🛡️ | **Two-Layer Defense** | Pair with [sockguard](https://github.com/codeswhat/sockguard) so the agent never touches the raw Docker socket directly. |
+| 🪶 | **Minimal Footprint** | Static Go binary, ~47 MB Wolfi (Chainguard) container image. CGO disabled, stripped, no external runtime dependencies. |
+| 🧩 | **Standalone Mode** | `ADAPTER=generic` provides a clean REST + SSE API on `/api/v1/*` backed by the local Docker daemon — no Drydock account required. |
-
Security
+
Feature Comparison
-Security model summary
-
-- **Authentication**: Fail-closed startup; raw tokens stored as fixed-size SHA-256 digests and compared timing-safely; hash-at-rest via `TOKEN_HASH` (Argon2id); Ed25519 per-client keypairs with versioned full-request-target signatures and replay protection
-- **Rate Limiting**: 10 failed auth attempts per IP per minute, plus a two-request cap on concurrent cold Argon2id derivations
-- **TLS**: TLS 1.2+ with modern AEAD cipher suites
-- **Compose Security**: Root-confined, symlink-resistant file writes; path traversal protection; env var denylist; service name injection prevention
-- **Resource Limits**: WebSocket (16 MB), response body (100 MB), exec sessions (100 concurrent)
-
-See [docs/security-model.md](docs/security-model.md) for the full citable spec and CVE mapping.
+How does Portwing compare to other remote Docker agents?
+
+> ✅ = supported ❌ = not supported ⚠️ = partial / limited ? = not documented or not evaluated † = archived, no longer maintained
+
+
Remote Docker agents
+
+
+
+
+
Feature
+
Portwing
+
Portainer Agent
+
Komodo Periphery
+
Arcane Agent
+
Hawser
+
+
+
+
Transparent Docker API proxy
✅
✅
❌
❌
✅
+
Inbound (controller-to-agent) connection
✅
✅
✅
✅
✅
+
Outbound edge connection
✅
✅
✅
✅
✅
+
Per-request signed HTTP authentication
✅
?
?
?
?
+
Optional mTLS for the agent link
❌
⚠️
?
✅
?
+
Default-deny socket filter in the documented deployment
✅ (with Sockguard)
❌
❌
⚠️
❌
+
Agent-level structured audit log
✅
⚠️
⚠️
⚠️
⚠️
+
Prometheus scrape endpoint on the agent
✅
?
?
?
?
+
Read-only MCP server
✅
?
?
?
?
+
Signed release evidence (cosign, SBOM, provenance)
✅
?
?
⚠️
⚠️
+
Fleet UI and controller workflows
❌
✅
✅
✅
?
+
License
AGPL-3.0
Zlib (agent) / proprietary Business features
GPL-3.0
BSD-3-Clause
MIT
+
+
+
+> Full v0.9 watcher/update behavior needs Drydock `v1.6.0-rc.11+`. The edge connection itself works with Drydock 1.6.x, or 1.5.x with `DD_EXPERIMENTAL_PORTWING=true`; see [COMPATIBILITY.md](COMPATIBILITY.md). Portwing has no RBAC, GitOps or Swarm support by design; the fleet product features belong in Drydock.
+
+
Update tools
+
+
+
+
+
Feature
+
Portwing
+
Diun
+
Watchtower †
+
+
+
+
Remote Docker API proxy
✅
❌
❌
+
Authenticated remote access
✅
❌
❌
+
Structured audit log
✅
❌
❌
+
Default-deny socket filter in the documented deployment
✅ (with Sockguard)
❌
❌
+
Read-only MCP server
✅
?
?
+
Outbound edge / NAT tunnel
✅
❌
❌
+
Image update detection or auto-update
❌
✅
✅
+
Single lightweight Go binary
✅
✅
✅
+
License
AGPL-3.0
MIT
Apache-2.0
+
+
+
+> Watchtower's upstream project is archived. Diun and Watchtower do update detection; Portwing is the access agent and leaves update decisions to Drydock.
+>
+> The remote-agent table is compiled from the published [competitive landscape](https://portwing.codeswhat.com/docs/competitive-landscape), which lists its primary sources and records unknown competitor behavior as "not documented" rather than guessing it absent. Compared versions: Portainer 2.39.5, Komodo Periphery v2.3.2, Arcane Agent v2.10.1, Hawser v0.2.46. Reviewed 2026-08-29; Arcane re-checked 2026-09-02. The update-tools table follows the Diun and Watchtower comparison pages, which pin no version or review date.
+> Contributions welcome if any information is inaccurate.
-
Audit Logging
+
Roadmap
-Structured JSON audit trail for every security-relevant action
-
-Portwing ships structured JSON audit logging for every security-relevant action — a feature that commercial container management platforms lock behind paid tiers.
-
-### Enable
-
-```bash
-# Write to a file (opened append-only, mode 0600)
-docker run -e AUDIT_LOG=/var/log/portwing-audit.log ...
-
-# Or to stdout/stderr (useful with log aggregators)
-docker run -e AUDIT_LOG=stdout ...
-```
-
-The persistent file/stdout/stderr sink is disabled by default (`AUDIT_LOG`
-unset). Independently, `AUDIT_BUFFER_SIZE` defaults to 256 and keeps recent
-records in an in-memory ring; set it to `0` to disable retention. The ring feeds
-`GET /_portwing/audit` and cursor-based NDJSON export at
-`GET /_portwing/audit/export`. Standard mode authenticates both paths. Edge
-mode exposes only the NDJSON export, without authentication, on its private
-operations listener; never publish that listener to an untrusted network. A
-409 response detects
-overwritten history or a restarted cursor generation instead of silently
-dropping records. The sink and export schemas are stable from v0.8.0.
-
-### Events
-
-| `event` | Triggered when |
-|---------|---------------|
-| `api_request` | Any authenticated API call completes |
-| `auth_failure` | An invalid token is presented |
-| `rate_limited` | An IP is blocked by the rate limiter |
-| `compose_op` | A Docker Compose operation runs |
-| `exec_start` | An interactive exec tunnel opens |
-| `enrollment` | An Ed25519 key is enrolled via `/api/portwing/enroll` |
-
-### Sample JSON lines
-
-```json
-{"time":"2026-01-15T10:23:45.123456789Z","level":"INFO","msg":"","event":"api_request","actor":"203.0.113.42","method":"POST","path":"/_portwing/compose","outcome":"allowed","status":200,"duration_ms":3.14}
-```
-
-Compose operations include additional fields:
-
-```json
-{"time":"2026-01-15T10:23:45.200Z","level":"INFO","msg":"","event":"compose_op","actor":"203.0.113.42","operation":"up","stack":"nginx-stack","outcome":"allowed"}
-```
-
-Exec tunnel events:
-
-```json
-{"time":"2026-01-15T10:24:01.500Z","level":"INFO","msg":"","event":"exec_start","actor":"203.0.113.42","container":"abc123def456","exec_id":"e7f8a9b1","outcome":"allowed"}
-```
+Version themes & highlights
+
+This direction covers at least the next twelve months, through August 2027.
+High-level themes only; see [ROADMAP.md](ROADMAP.md) for direction and non-goals and [CHANGELOG.md](CHANGELOG.md) for per-release detail.
+
+| Version | Theme | Highlights |
+| --- | --- | --- |
+| **v0.1.x** ✅ | Foundation | Transparent Docker API proxy, standard-mode HTTP server, edge-mode WebSocket tunnel, Drydock adapter, SSE event stream, token auth with timing-safe comparison, rate limiting, multi-arch image |
+| **v0.2.x** ✅ | Security & Observability | Ed25519 per-request auth, key enrollment, Argon2id token hashing, read-only MCP server, Prometheus metrics, structured audit logging, generic REST adapter, cosign keyless signing, OpenAPI 3.1 spec |
+| **v0.3.x** ✅ | Rename & Edge Fixes | Lookout renamed to Portwing, startup banner, GoReleaser `dockers_v2` migration, edge reconnect backoff and read-deadline fixes |
+| **v0.4.x** ✅ | Quality Gates | Monthly deep fuzzing, weekly soak test, monthly benchmark tracking, edge tunnel test harness, edge exec input ordering and outbound backpressure fixes |
+| **v0.5.x** ✅ | Hardening | Request and application Prometheus metrics, audit ring buffer and `GET /_portwing/audit`, Kubernetes examples, pre-auth request body cap, private-key permission check, outbound TLS 1.2 floor, edge mode requires `PRIVATE_KEY_FILE` |
+| **v0.6.0** ✅ | Compatibility & Non-Root | Container image runs as non-root UID 65532, `COMPATIBILITY.md` cross-repo version matrix, edge container deletion, CI egress lockdown |
+| **v0.7.x** ✅ | Fail-Closed Standard Mode | Standard mode refuses to start without credentials, security hardening pass (PW-SEC-001 to 010), edge log and delete request correlation, edge reconnect classification of terminal hello rejections, dead `DOCKER_HOST` surface removed |
+| **v0.8.x** ✅ | Operations & Distribution | Mode-aware `/health` and `/ready`, cursor-based NDJSON audit export, runnable Compose and Kubernetes observability examples, continuous edge logs, Homebrew cask and signed `deb`/`rpm` packages, published stability policy, edge mode production supported |
+| **v0.9.x** ✅ | Controller-Owned Updates | Controller-owned Drydock watcher and update execution (Drydock `v1.6.0-rc.11+`), edge audit export, loopback default for the edge operations listener, MCP revision 2026-07-28, `ALLOWED_ORIGINS` and `ALLOWED_HOSTS` browser origin and Host checks (v0.9.23) |
+| **v1.0.0** | Binding Stability | `STABILITY.md` guarantees become binding semver commitments, final re-verify of the competitive review against primary sources, decision on a versioned docs archive. Gated on verifiable items, not a date |
+| **Post-v1** | Demand-Driven | Controller-managed Portwing upgrade and rollback waves, optional client-certificate authentication, polling/intermittent edge transport, controller-assisted two-key rotation, BuildKit-aware Sockguard profile |
+
+SLSA Build L3 isn't tied to a version. It follows an org-shared reusable release workflow landing.
-
-
-Growth over time:
+
-Generated from GitHub's own stargazer timestamps and committed to the repo, so rendering it costs no third-party request. Refreshed at each release rather than on a schedule, so it never changes underneath a tag that already points at it; each chart ships with the following release. See `.github/workflows/starchart.yml`.
-
---
-[](https://semver.org/)
-[](https://www.conventionalcommits.org/)
-[](https://keepachangelog.com/)
-
-### Built With
+
Built With
[](https://go.dev/)
[](https://github.com/gorilla/websocket)
@@ -1069,22 +497,43 @@ Growth over time:
[](https://edu.chainguard.dev/open-source/wolfi/overview/)
[](https://www.docker.com/)
[](https://goreleaser.com/)
+[](https://claude.ai/)
+[](https://openai.com)
-### Community & Support
+[](https://semver.org/)
+[](https://www.conventionalcommits.org/)
+[](https://keepachangelog.com/)
-Non-security bugs and concrete feature requests go to [GitHub Issues](https://github.com/CodesWhat/portwing/issues); open-ended questions, ideas, and design discussion go to [GitHub Discussions](https://github.com/CodesWhat/portwing/discussions); real-time chat and early support are on the [CodesWhat Discord](https://discord.gg/mWHCPJRzSx). **Vulnerabilities must not be filed as public issues** — see [SECURITY.md](SECURITY.md) for private disclosure.
+
Community & Support
-Issues, ideas, and pull requests are welcome — start with [CONTRIBUTING.md](CONTRIBUTING.md), and use [SECURITY.md](SECURITY.md) for private vulnerability disclosure.
+Real-time chat and early support: **[CodesWhat Discord](https://discord.gg/mWHCPJRzSx)**
-Every release image is cosign-signed via GitHub Actions OIDC. Before running a Portwing image in production, verify it with the canonical invocation in the [Verify a Release](#verify-a-release) section above.
+Non-security bugs and concrete feature requests go to **[GitHub Issues](https://github.com/CodesWhat/portwing/issues)**; open-ended questions, ideas, and design discussion go to **[GitHub Discussions](https://github.com/CodesWhat/portwing/discussions)**. **Vulnerabilities must not be filed as public issues** — see **[SECURITY.md](SECURITY.md)** for private disclosure. Pull requests are welcome; start with [CONTRIBUTING.md](CONTRIBUTING.md).
-**[AGPL-3.0 License](LICENSE)**
+
Docker socket proxy — default-deny allowlist filter protecting the socket
+
+
+These three tools are designed to layer: sockguard filters the socket, portwing exposes it remotely, and drydock monitors and acts on container state.
-Built by CodesWhat
+See [COMPATIBILITY.md](COMPATIBILITY.md) for the full compatibility matrix across all three tools.
+
+---
+
+**[AGPL-3.0 License](LICENSE)**
-[](https://ko-fi.com/codeswhat)
-[](https://buymeacoffee.com/codeswhat)
-[](https://github.com/sponsors/CodesWhat)
+
+
+
+
+
+
+Back to top
diff --git a/docs/assets/codeswhat-logo-dark.svg b/docs/assets/codeswhat-logo-dark.svg
new file mode 100644
index 00000000..f2b6de9d
--- /dev/null
+++ b/docs/assets/codeswhat-logo-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/codeswhat-logo-original.svg b/docs/assets/codeswhat-logo-original.svg
new file mode 100644
index 00000000..8d0fe9d7
--- /dev/null
+++ b/docs/assets/codeswhat-logo-original.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/docs/content/docs/authentication.mdx b/docs/content/docs/authentication.mdx
index 48ddd123..d41627db 100644
--- a/docs/content/docs/authentication.mdx
+++ b/docs/content/docs/authentication.mdx
@@ -17,6 +17,8 @@ non-loopback address requires the separate
`ALLOW_UNAUTHENTICATED_REMOTE=true` acknowledgement.
+The examples below publish port 3000 on host loopback only. For remote access, configure Portwing TLS before widening that bind, or keep the plaintext listener private behind a TLS-terminating reverse proxy.
+
---
## 1. Plaintext token
@@ -29,7 +31,7 @@ docker run -d --name portwing \
--group-add $(stat -c '%g' /var/run/docker.sock) \
-v /var/run/docker.sock:/var/run/docker.sock \
-e TOKEN="$TOKEN" \
- -p 3000:3000 \
+ -p 127.0.0.1:3000:3000 \
ghcr.io/codeswhat/portwing:latest
```
@@ -53,7 +55,7 @@ docker run -d --name portwing \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /run/secrets/portwing-token:/run/secrets/portwing-token:ro \
-e TOKEN_FILE=/run/secrets/portwing-token \
- -p 3000:3000 \
+ -p 127.0.0.1:3000:3000 \
ghcr.io/codeswhat/portwing:latest
```
@@ -78,7 +80,7 @@ docker run -d --name portwing \
--group-add $(stat -c '%g' /var/run/docker.sock) \
-v /var/run/docker.sock:/var/run/docker.sock \
-e TOKEN_HASH="$HASH" \
- -p 3000:3000 \
+ -p 127.0.0.1:3000:3000 \
ghcr.io/codeswhat/portwing:latest
```
@@ -148,7 +150,7 @@ docker run -d --name portwing \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /etc/portwing/authorized_keys:/etc/portwing/authorized_keys:ro \
-e AUTHORIZED_KEYS=/etc/portwing/authorized_keys \
- -p 3000:3000 \
+ -p 127.0.0.1:3000:3000 \
ghcr.io/codeswhat/portwing:latest
```
diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx
index 65d690cd..aa910f80 100644
--- a/docs/content/docs/configuration.mdx
+++ b/docs/content/docs/configuration.mdx
@@ -14,7 +14,7 @@ For auth-method selection and key-enrollment flows, see [Authentication](/authen
| Variable | Default | Description |
|---|---|---|
| `PORT` | `3000` | TCP port the HTTP server listens on. |
-| `BIND_ADDRESS` | Standard: `0.0.0.0`; Edge: `127.0.0.1` | IP address the HTTP/operations listener binds to. Edge mode defaults to loopback because its operations routes have no inbound authentication; explicitly use a broader address only on an isolated monitoring network. |
+| `BIND_ADDRESS` | Standard: `0.0.0.0`; Edge: `127.0.0.1` | IP address the HTTP/operations listener binds to. Edge mode defaults to loopback because its operations routes have no inbound authentication, and it refuses a non-loopback address unless `ALLOW_UNAUTHENTICATED_REMOTE=true` is set; use a broader address only on an isolated monitoring network. |
| `DRYDOCK_URL` | — | WebSocket URL for Edge mode (e.g. `wss://drydock.example.com`). When set, Portwing operates in Edge mode and connects outbound to Drydock. A plaintext `http://`/`ws://` scheme is rejected at startup unless `ALLOW_INSECURE_EDGE_URL=true`. |
| `ALLOW_INSECURE_EDGE_URL` | `false` | Dangerous opt-in to connect Edge mode to a plaintext `http://`/`ws://` `DRYDOCK_URL`. The controller's identity is otherwise verified by TLS alone, so this is for trusted local testing only. |
| `TLS_CERT` | — | Path to server TLS certificate (Standard mode). Must be set together with `TLS_KEY`; partial configuration is a startup error. |
diff --git a/docs/content/docs/connection-modes.mdx b/docs/content/docs/connection-modes.mdx
index dd90ac5b..781efe8d 100644
--- a/docs/content/docs/connection-modes.mdx
+++ b/docs/content/docs/connection-modes.mdx
@@ -23,7 +23,7 @@ Otherwise
Portwing runs a plain HTTP server on `PORT` (default `3000`). The Drydock controller connects inbound and drives the session: it calls `GET /api/containers`, `GET /api/watchers`, and `GET /api/triggers` during the handshake, then holds a long-lived SSE stream on `GET /api/events` for real-time container events. All Docker Engine API paths are transparently proxied through the agent.
-Standard mode is active whenever `DRYDOCK_URL` is **not** set.
+Standard mode is active whenever `DRYDOCK_URL` is **not** set. The controller authenticates with the `X-Dd-Agent-Secret` shared secret. Built-in TLS (`TLS_CERT` / `TLS_KEY`) accepts TLS 1.2 and later with modern cipher suites.
### Required networking
@@ -95,6 +95,8 @@ Edge mode is active when **both** of the following are true:
Setting `DRYDOCK_URL` without `PRIVATE_KEY_FILE` is rejected at startup with a fatal error — the Drydock controller endpoint is Ed25519-only, so the agent enforces `PRIVATE_KEY_FILE` at startup rather than looping forever on a rejected hello. `TOKEN` and `AUTHORIZED_KEYS` are not accepted for edge mode.
+Edge mode's trust in the controller is TLS-only and one-directional. The signed hello lets the controller verify Portwing, but nothing verifies the controller's identity in return, so a plaintext `http://` or `ws://` `DRYDOCK_URL` would let an on-path attacker complete the handshake and drive dockerd. Portwing fails closed on a plaintext `DRYDOCK_URL` unless `ALLOW_INSECURE_EDGE_URL=true` is set, which is for trusted local testing only. Always use `https://` or `wss://` against a real controller.
+
### Key environment variables
| Variable | Default | Purpose |
@@ -123,6 +125,9 @@ On each connection attempt the agent:
3. Waits up to `WELCOME_TIMEOUT` seconds for the controller's `welcome` response.
4. On success, sends component descriptors (`dd:component_sync`) before the
initial container inventory (`dd:container_sync`), then sends host metrics.
+ The component descriptors declare watcher ownership as controller-owned, so
+ controller-owned Docker calls travel as correlated WebSocket `request` /
+ `response` messages.
### Reconnection and keepalive
diff --git a/docs/content/docs/drydock-integration.mdx b/docs/content/docs/drydock-integration.mdx
index b14a152a..55c6634b 100644
--- a/docs/content/docs/drydock-integration.mdx
+++ b/docs/content/docs/drydock-integration.mdx
@@ -386,6 +386,8 @@ 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.
+Sockguard only includes the detailed reason in its `reason` field when the preset sets `deny_verbosity: verbose`. Under the default `minimal` verbosity the surfaced message is the generic `request denied by sockguard policy`.
+
**Drydock-specific (`dd:` namespace):**
| Type | Direction | Purpose |
diff --git a/docs/content/docs/standalone-mode.mdx b/docs/content/docs/standalone-mode.mdx
index 09b27d9c..2c8667f5 100644
--- a/docs/content/docs/standalone-mode.mdx
+++ b/docs/content/docs/standalone-mode.mdx
@@ -20,7 +20,7 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-e ADAPTER=generic \
-e TOKEN=my-secret \
- -p 3000:3000 \
+ -p 127.0.0.1:3000:3000 \
ghcr.io/codeswhat/portwing:latest
```
diff --git a/scripts/package-release-config-test-test.sh b/scripts/package-release-config-test-test.sh
index 562a60a3..0909429f 100644
--- a/scripts/package-release-config-test-test.sh
+++ b/scripts/package-release-config-test-test.sh
@@ -328,7 +328,7 @@ prefix_version="${release_version}0"
readme_prefix_backup="${fixture}/README.md.prefix-version-backup"
cp "${fixture}/README.md" "${readme_prefix_backup}"
-awk -v from="VERSION=${release_version}" -v to="VERSION=${prefix_version}" '
+awk -v from="portwing_${release_version}_" -v to="portwing_${prefix_version}_" '
!changed && index($0, from) {
sub(from, to)
changed = 1
diff --git a/scripts/package-release-config-test.sh b/scripts/package-release-config-test.sh
index de087365..9cbdb0c9 100755
--- a/scripts/package-release-config-test.sh
+++ b/scripts/package-release-config-test.sh
@@ -688,11 +688,11 @@ require_file "docs/assets/star-history-dark.svg" \
# grep for the img tag would be satisfied by any other occurrence in the README,
# and naming hosts one at a time only ever catches the ones already known.
star_section="$(awk '
- /<\/a>/ { collecting = 1 }
+ /id="star-history"/ { collecting = 1 }
collecting { print; if ($0 == "---") exit }
' README.md)"
if [ -z "${star_section}" ]; then
- echo 'FAIL: the README must keep the star-history section (no anchor found in README.md)' >&2
+ echo 'FAIL: the README must keep the star-history section (no id="star-history" heading found in README.md)' >&2
failures=$((failures + 1))
fi
# The whitespace before src is load-bearing: "src=" as a bare substring is also
@@ -825,7 +825,7 @@ fi
require_text "README.md" "brew install --cask codeswhat/tap/portwing" "the repository landing page must advertise Homebrew installation"
require_text "README.md" "/docs/installation" "the repository landing page must link the full package guide"
# shellcheck disable=SC2016 # The documented shell command expands VERSION.
-require_text "README.md" 'release.yml@refs/tags/v${VERSION}' "public verification instructions must bind signatures to the selected tag"
+require_text "docs/content/docs/verification.mdx" 'release.yml@refs/tags/v${VERSION}' "public verification instructions must bind signatures to the selected tag"
require_text "RELEASING.md" "HOMEBREW_TAP_TOKEN" "maintainer release docs must name the tap publishing credential"
require_text "RELEASING.md" "verify-native-packages" "maintainer release docs must describe the native package gate"
require_text "website/src/components/get-started.tsx" "codeswhat/tap/portwing" "the website must advertise the Homebrew cask"
diff --git a/scripts/standard-mode-bind-config-test.sh b/scripts/standard-mode-bind-config-test.sh
index 5307dc2d..9584f651 100644
--- a/scripts/standard-mode-bind-config-test.sh
+++ b/scripts/standard-mode-bind-config-test.sh
@@ -96,7 +96,7 @@ require_publications "examples/docker-compose.with-sockguard.yml" "yaml" 1 \
"the canonical standard-mode sockguard Compose example"
require_publications "README.md" "yaml" 1 \
"the README copy of the standard-mode sockguard example"
-require_publications "README.md" "docker run" 4 \
+require_publications "README.md" "docker run" 1 \
"README standard-mode docker run instructions"
require_publications "docs/content/docs/getting-started.mdx" "yaml" 2 \
"getting-started copies of the standard-mode Compose examples"