From c2cfe646aadcb351534b9d7f9455f7a4cc042ee6 Mon Sep 17 00:00:00 2001
From: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:30:04 -0400
Subject: [PATCH 1/6] docs: carry README-only caveats into the docs pages
The README is being slimmed to drydock's section set, so anything it
held that the docs pages lacked moves here first: the optional mTLS
and TLS 1.2+ note for standard mode, the edge controller-trust
paragraph and correlated request/response ownership, the BIND_ADDRESS
refusal clause, and the loopback port publishing in the auth and
standalone examples.
---
docs/content/docs/authentication.mdx | 10 ++++++----
docs/content/docs/configuration.mdx | 2 +-
docs/content/docs/connection-modes.mdx | 7 ++++++-
docs/content/docs/standalone-mode.mdx | 2 +-
4 files changed, 14 insertions(+), 7 deletions(-)
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..8476e632 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, with optional mTLS. 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/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
```
From f759297d40684d1778990abbc788a3203e1769f4 Mon Sep 17 00:00:00 2001
From: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:35:47 -0400
Subject: [PATCH 2/6] docs(drydock-integration): note deny_verbosity gates the
exec denial reason
---
docs/content/docs/drydock-integration.mdx | 2 ++
1 file changed, 2 insertions(+)
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 |
From 4e29f54e4b4a951c40ca7415511f7629571f8c63 Mon Sep 17 00:00:00 2001
From: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:35:47 -0400
Subject: [PATCH 3/6] docs(readme): match drydock's README shape
Slim the body to Documentation, Quick Start, Why Portwing, Features,
Feature Comparison, Roadmap and Star History, with drydock's centered
section headings, footer order and ecosystem table. The reference
material (authentication, connection modes, standalone mode,
configuration, API and MCP, stability, credential options, release
verification, security, audit logging) lives on the docs site and is
linked from the Documentation table.
Adds the CodesWhat logo pair under docs/assets, switches the license
badge to the auto one, and drops the Ko-fi and Buy Me a Coffee badges.
The release contract pinned three things to the old layout: the
tag-bound cosign command, the star-history anchor, and the README
docker run count. The cosign check now reads
docs/content/docs/verification.mdx, the star-history check keys on the
section's id, and the docker run count is 1.
---
README.md | 998 +++++-----------------
docs/assets/codeswhat-logo-dark.svg | 1 +
docs/assets/codeswhat-logo-original.svg | 3 +
scripts/package-release-config-test.sh | 6 +-
scripts/standard-mode-bind-config-test.sh | 2 +-
5 files changed, 231 insertions(+), 779 deletions(-)
create mode 100644 docs/assets/codeswhat-logo-dark.svg
create mode 100644 docs/assets/codeswhat-logo-original.svg
diff --git a/README.md b/README.md
index 587e9b2e..9c7b4ad0 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
-
+
@@ -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.** 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.
+
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
+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.
-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. 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`. Use `PRIVATE_KEY_FILE` for signed edge-mode hellos. See [Authentication](https://portwing.codeswhat.com/docs/authentication).
-**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.
+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,185 @@ 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.
-
-
-
-
-
-
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. |
-
-
-
-
Authentication
-
-
-Token Authentication (quickstart)
-
-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
-```
-
-**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
+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).
-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.
-
-
+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.
-
Standalone (Generic) Mode
+
Why Portwing
-
-Run without a Drydock platform connection
+Controlling a remote Docker host usually means exposing the Docker socket, or running an agent that holds it with little in the way of authentication, audit, or containment. 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.
-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.
+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.
-```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 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. |
-
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 † = 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 Docker socket filter
✅
❌
❌
⚠️
❌
+
Agent-level structured audit log
✅
⚠️
⚠️
⚠️
⚠️
+
Prometheus scrape endpoint on the agent
✅
?
?
?
?
+
Read-only MCP server
✅
?
?
?
?
+
Signed release evidence (cosign, SBOM, provenance)
✅
❌
❌
⚠️
⚠️
+
Fleet UI, RBAC, GitOps, upgrade policies
❌
✅
✅
✅
?
+
License
AGPL-3.0
Zlib (agent) / proprietary Business features
GPL-3.0
BSD-3-Clause
MIT
+
+
+
+> Portwing's edge mode and full v0.9 watcher/update behavior need Drydock `v1.6.0-rc.11+`. 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 Docker socket filter
✅
❌
❌
+
Prometheus metrics
✅
✅
❌
+
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.
+>
+> Compiled from the published [competitive landscape](https://portwing.codeswhat.com/docs/competitive-landscape), which cites primary sources for each claim. Compared versions: Portainer 2.39, Komodo Periphery v2.3.2, Arcane Agent v2.10.1, Hawser v0.2.46. Reviewed 2026-08-29.
+> 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 once an org-shared reusable release workflow exists |
-
-
-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 +496,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/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"
From 134b5d2c8eb4c1ac59c49ee81dbf345371046077 Mon Sep 17 00:00:00 2001
From: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:37:32 -0400
Subject: [PATCH 4/6] docs(readme): describe what v0.9.23 changed and drop an
mTLS claim the server doesn't back
The current-release note still carried the v0.9.21 text saying agent behavior was unchanged. v0.9.23 added the Origin and Host checks and the exec_start admission outcome.
Portwing's TLS listener sets no client-certificate verification, and ROADMAP.md lists client-certificate authentication as post-v1, so standard mode has no optional mTLS to document.
---
README.md | 2 +-
docs/content/docs/connection-modes.mdx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 9c7b4ad0..d179c017 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@
> [!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.
+> **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
diff --git a/docs/content/docs/connection-modes.mdx b/docs/content/docs/connection-modes.mdx
index 8476e632..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. The controller authenticates with the `X-Dd-Agent-Secret` shared secret, with optional mTLS. Built-in TLS (`TLS_CERT` / `TLS_KEY`) accepts TLS 1.2 and later with modern cipher suites.
+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
From 36d8ad77e0634856fb6b94338484616dc05253c4 Mon Sep 17 00:00:00 2001
From: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:39:40 -0400
Subject: [PATCH 5/6] test(release): mutate a README asset name in the
stale-version self-test
The self-test bumped a VERSION= line in README.md to prove the stale-version guard fires. That command now lives on the docs verification page, so the mutation found nothing and the self-test exited 1 with no output. It now bumps the portwing__ asset name, which the README still carries and the same guard covers.
---
scripts/package-release-config-test-test.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 129f768ec9f8455ca820eb2e8fb9ec253ebfe8ee Mon Sep 17 00:00:00 2001
From: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:17:56 -0400
Subject: [PATCH 6/6] docs(readme): hold the comparison tables to what the
landscape records
Portainer and Komodo release evidence were marked absent where the landscape says not evaluated. The Drydock version note overstated what edge mode needs, the review note gave one date for two reviews, and the update-tools table carried a metrics row nobody has checked upstream. Cells the sources don't state are now '?'.
---
README.md | 27 ++++++++++++++-------------
1 file changed, 14 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index d179c017..a29de7b5 100644
--- a/README.md
+++ b/README.md
@@ -316,7 +316,7 @@ See the [Getting Started guide](https://portwing.codeswhat.com/docs/getting-star
Why Portwing
-Controlling a remote Docker host usually means exposing the Docker socket, or running an agent that holds it with little in the way of authentication, audit, or containment. 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.
+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.
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.
@@ -361,9 +361,9 @@ flowchart LR
| 🤖 | **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. |
+| 📜 | **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 100 concurrent session cap. |
+| ⌨️ | **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. |
@@ -378,7 +378,7 @@ flowchart LR
How does Portwing compare to other remote Docker agents?
-> ✅ = supported ❌ = not supported ⚠️ = partial / limited ? = not documented † = archived, no longer maintained
+> ✅ = supported ❌ = not supported ⚠️ = partial / limited ? = not documented or not evaluated † = archived, no longer maintained
Remote Docker agents
@@ -399,17 +399,17 @@ flowchart LR
Outbound edge connection
✅
✅
✅
✅
✅
Per-request signed HTTP authentication
✅
?
?
?
?
Optional mTLS for the agent link
❌
⚠️
?
✅
?
-
Default-deny Docker socket filter
✅
❌
❌
⚠️
❌
+
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, RBAC, GitOps, upgrade policies
❌
✅
✅
✅
?
+
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
-> Portwing's edge mode and full v0.9 watcher/update behavior need Drydock `v1.6.0-rc.11+`. Portwing has no RBAC, GitOps or Swarm support by design; the fleet product features belong in Drydock.
+> 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
@@ -426,9 +426,8 @@ flowchart LR
Remote Docker API proxy
✅
❌
❌
Authenticated remote access
✅
❌
❌
Structured audit log
✅
❌
❌
-
Default-deny Docker socket filter
✅
❌
❌
-
Prometheus metrics
✅
✅
❌
-
Read-only MCP server
✅
❌
❌
+
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
✅
✅
✅
@@ -438,7 +437,7 @@ flowchart LR
> Watchtower's upstream project is archived. Diun and Watchtower do update detection; Portwing is the access agent and leaves update decisions to Drydock.
>
-> Compiled from the published [competitive landscape](https://portwing.codeswhat.com/docs/competitive-landscape), which cites primary sources for each claim. Compared versions: Portainer 2.39, Komodo Periphery v2.3.2, Arcane Agent v2.10.1, Hawser v0.2.46. Reviewed 2026-08-29.
+> 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.
@@ -465,7 +464,9 @@ High-level themes only; see [ROADMAP.md](ROADMAP.md) for direction and non-goals
| **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 once an org-shared reusable release workflow exists |
+| **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.