diff --git a/README.md b/README.md index 587e9b2e..a29de7b5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

Release Multi-arch - License AGPL-3.0 + License
CI OpenSSF Scorecard @@ -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). -

Documentation

| Resource | Link | | --- | --- | | Website | [portwing.codeswhat.com](https://portwing.codeswhat.com) | | Docs | [portwing.codeswhat.com/docs](https://portwing.codeswhat.com/docs) | +| Getting Started | [Getting Started](https://portwing.codeswhat.com/docs/getting-started) | +| Installation | [Native packages and install guide](https://portwing.codeswhat.com/docs/installation) | +| Authentication | [Token, hash-at-rest, and Ed25519 keys](https://portwing.codeswhat.com/docs/authentication) | +| Connection Modes | [Standard and Edge](https://portwing.codeswhat.com/docs/connection-modes) | +| Standalone Mode | [Generic REST and SSE adapter](https://portwing.codeswhat.com/docs/standalone-mode) | +| Configuration | [Environment variable reference](https://portwing.codeswhat.com/docs/configuration) | +| API Reference | [HTTP endpoints](https://portwing.codeswhat.com/docs/api-reference) | +| MCP Server | [AI assistant integration and client setup](https://portwing.codeswhat.com/docs/mcp-server) | +| Observability | [Prometheus metrics and health endpoints](https://portwing.codeswhat.com/docs/observability) | +| Audit Logging | [Structured audit trail](https://portwing.codeswhat.com/docs/audit-logging) | +| Verifying Releases | [Cosign, attestations, checksums, SBOM](https://portwing.codeswhat.com/docs/verification) | +| Security Model | [Website](https://portwing.codeswhat.com/docs/security-model), [`docs/security-model.md`](docs/security-model.md) | +| Stability Policy | [Website](https://portwing.codeswhat.com/docs/stability-policy), [`STABILITY.md`](STABILITY.md), [`COMPATIBILITY.md`](COMPATIBILITY.md) | | Competitive Landscape | [portwing.codeswhat.com/docs/competitive-landscape](https://portwing.codeswhat.com/docs/competitive-landscape) | -| Security Model | [`docs/security-model.md`](docs/security-model.md) | -| Ed25519 Auth Design | [`docs/design/ed25519-auth.md`](docs/design/ed25519-auth.md) | -| Watchtower Migration | [`docs/migrating-from-watchtower.md`](docs/migrating-from-watchtower.md) | | Drydock Integration | [`docs/drydock-integration.md`](docs/drydock-integration.md) | +| Watchtower Migration | [`docs/migrating-from-watchtower.md`](docs/migrating-from-watchtower.md) | +| Ed25519 Auth Design | [`docs/design/ed25519-auth.md`](docs/design/ed25519-auth.md) | | Benchmarks | [`BENCHMARKS.md`](BENCHMARKS.md) | | OpenAPI Spec | [`api/openapi.yaml`](api/openapi.yaml) | | Changelog | [`CHANGELOG.md`](CHANGELOG.md) | +| Roadmap | See [Roadmap](#roadmap) section below | | Contributing | [`CONTRIBUTING.md`](CONTRIBUTING.md) | | Code of Conduct | [Contributor Covenant 2.1](CODE_OF_CONDUCT.md) | | Governance | [`GOVERNANCE.md`](GOVERNANCE.md) | @@ -117,9 +95,7 @@ flowchart LR

Quick Start

-### 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

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FeaturePortwingPortainer AgentKomodo PeripheryArcane AgentHawser
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❌✅✅✅?
LicenseAGPL-3.0Zlib (agent) / proprietary Business featuresGPL-3.0BSD-3-ClauseMIT
+ +> 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

+ + + + + + + + + + + + + + + + + + + + + +
FeaturePortwingDiunWatchtower †
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✅✅✅
LicenseAGPL-3.0MITApache-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: +

Star History

- Star history for CodesWhat/portwing + Star history for CodesWhat/portwing
-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`. - ---
-[![SemVer](https://img.shields.io/badge/semver-2.0.0-blue)](https://semver.org/) -[![Conventional Commits](https://img.shields.io/badge/commits-conventional-fe5196?logo=conventionalcommits&logoColor=fff)](https://www.conventionalcommits.org/) -[![Keep a Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog-E05735)](https://keepachangelog.com/) - -### Built With +

Built With

[![Go 1.27](https://img.shields.io/badge/Go_1.27-00ADD8?logo=go&logoColor=fff)](https://go.dev/) [![gorilla/websocket](https://img.shields.io/badge/gorilla%2Fwebsocket-00ADD8?logo=go&logoColor=fff)](https://github.com/gorilla/websocket) @@ -1069,22 +497,43 @@ Growth over time: [![Wolfi](https://img.shields.io/badge/Wolfi-4A4A55?logo=chainguard&logoColor=fff)](https://edu.chainguard.dev/open-source/wolfi/overview/) [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff)](https://www.docker.com/) [![GoReleaser](https://img.shields.io/badge/GoReleaser-00ADD8?logo=go&logoColor=fff)](https://goreleaser.com/) +[![Anthropic](https://img.shields.io/badge/Anthropic-CC785C?style=flat&logo=anthropic&logoColor=white)](https://claude.ai/) +[![OpenAI](https://img.shields.io/badge/OpenAI-10A37F?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU%2BT3BlbkFJPC90aXRsZT48cGF0aCBmaWxsPSIjZmZmZmZmIiBkPSJNMjIuMjgxOSA5LjgyMTFhNS45ODQ3IDUuOTg0NyAwIDAgMC0uNTE1Ny00LjkxMDggNi4wNDYyIDYuMDQ2MiAwIDAgMC02LjUwOTgtMi45QTYuMDY1MSA2LjA2NTEgMCAwIDAgNC45ODA3IDQuMTgxOGE1Ljk4NDcgNS45ODQ3IDAgMCAwLTMuOTk3NyAyLjkgNi4wNDYyIDYuMDQ2MiAwIDAgMCAuNzQyNyA3LjA5NjYgNS45OCA1Ljk4IDAgMCAwIC41MTEgNC45MTA3IDYuMDUxIDYuMDUxIDAgMCAwIDYuNTE0NiAyLjkwMDFBNS45ODQ3IDUuOTg0NyAwIDAgMCAxMy4yNTk5IDI0YTYuMDU1NyA2LjA1NTcgMCAwIDAgNS43NzE4LTQuMjA1OCA1Ljk4OTQgNS45ODk0IDAgMCAwIDMuOTk3Ny0yLjkwMDEgNi4wNTU3IDYuMDU1NyAwIDAgMC0uNzQ3NS03LjA3Mjl6bS05LjAyMiAxMi42MDgxYTQuNDc1NSA0LjQ3NTUgMCAwIDEtMi44NzY0LTEuMDQwOGwuMTQxOS0uMDgwNCA0Ljc3ODMtMi43NTgyYS43OTQ4Ljc5NDggMCAwIDAgLjM5MjctLjY4MTN2LTYuNzM2OWwyLjAyIDEuMTY4NmEuMDcxLjA3MSAwIDAgMSAuMDM4LjA1MnY1LjU4MjZhNC41MDQgNC41MDQgMCAwIDEtNC40OTQ1IDQuNDk0NHptLTkuNjYwNy00LjEyNTRhNC40NzA4IDQuNDcwOCAwIDAgMS0uNTM0Ni0zLjAxMzdsLjE0Mi4wODUyIDQuNzgzIDIuNzU4MmEuNzcxMi43NzEyIDAgMCAwIC43ODA2IDBsNS44NDI4LTMuMzY4NXYyLjMzMjRhLjA4MDQuMDgwNCAwIDAgMS0uMDMzMi4wNjE1TDkuNzQgMTkuOTUwMmE0LjQ5OTIgNC40OTkyIDAgMCAxLTYuMTQwOC0xLjY0NjR6TTIuMzQwOCA3Ljg5NTZhNC40ODUgNC40ODUgMCAwIDEgMi4zNjU1LTEuOTcyOFYxMS42YS43NjY0Ljc2NjQgMCAwIDAgLjM4NzkuNjc2NWw1LjgxNDQgMy4zNTQzLTIuMDIwMSAxLjE2ODVhLjA3NTcuMDc1NyAwIDAgMS0uMDcxIDBsLTQuODMwMy0yLjc4NjVBNC41MDQgNC41MDQgMCAwIDEgMi4zNDA4IDcuODcyem0xNi41OTYzIDMuODU1OEwxMy4xMDM4IDguMzY0IDE1LjExOTIgNy4yYS4wNzU3LjA3NTcgMCAwIDEgLjA3MSAwbDQuODMwMyAyLjc5MTNhNC40OTQ0IDQuNDk0NCAwIDAgMS0uNjc2NSA4LjEwNDJ2LTUuNjc3MmEuNzkuNzkgMCAwIDAtLjQwNy0uNjY3em0yLjAxMDctMy4wMjMxbC0uMTQyLS4wODUyLTQuNzczNS0yLjc4MThhLjc3NTkuNzc1OSAwIDAgMC0uNzg1NCAwTDkuNDA5IDkuMjI5N1Y2Ljg5NzRhLjA2NjIuMDY2MiAwIDAgMSAuMDI4NC0uMDYxNWw0LjgzMDMtMi43ODY2YTQuNDk5MiA0LjQ5OTIgMCAwIDEgNi42ODAyIDQuNjZ6TTguMzA2NSAxMi44NjNsLTIuMDItMS4xNjM4YS4wODA0LjA4MDQgMCAwIDEtLjAzOC0uMDU2N1Y2LjA3NDJhNC40OTkyIDQuNDk5MiAwIDAgMSA3LjM3NTctMy40NTM3bC0uMTQyLjA4MDVMOC43MDQgNS40NTlhLjc5NDguNzk0OCAwIDAgMC0uMzkyNy42ODEzem0xLjA5NzYtMi4zNjU0bDIuNjAyLTEuNDk5OCAyLjYwNjkgMS40OTk4djIuOTk5NGwtMi41OTc0IDEuNDk5Ny0yLjYwNjctMS40OTk3WiIvPjwvc3ZnPg%3D%3D)](https://openai.com) -### Community & Support +[![SemVer](https://img.shields.io/badge/semver-2.0.0-blue)](https://semver.org/) +[![Conventional Commits](https://img.shields.io/badge/commits-conventional-fe5196?logo=conventionalcommits&logoColor=fff)](https://www.conventionalcommits.org/) +[![Keep a Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog-E05735)](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)** +

CodesWhat Ecosystem

+ + + + + + +
ToolRole
drydockContainer update monitoring — web UI and notification engine
portwingRemote Docker agent — secure socket-level access from Drydock or standalone
sockguardDocker 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)** -[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=kofi&logoColor=white)](https://ko-fi.com/codeswhat) -[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/codeswhat) -[![Sponsor](https://img.shields.io/badge/Sponsor-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/CodesWhat) + + + + + 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"