Container inventory

June 23, 2026 Β· View on GitHub

Portwing

Portwing

Security-first remote Docker agent β€” control your containers from anywhere, safely.

Status: Alpha

Warning

🚧 Alpha software β€” not yet production-ready

Portwing is in active alpha (v0.3.x). APIs, environment variables, and on-disk/wire formats may change between minor releases without notice. Pin to an exact version, review the CHANGELOG before upgrading, and expect breaking changes before v1.0.0.

Release GHCR
Multi-arch Image size License AGPL-3.0

Stars Forks Issues Last commit Commit activity
Repo size Repo views

CI Vulnerability Scan Nightly fuzz
Go Report Card Go Reference Coverage OpenSSF Scorecard


πŸ“‘ Contents


Note

v0.3.0 is the current release. Adds a startup banner, completes the rename from Lookout to Portwing, migrates the release pipeline to GoReleaser dockers_v2, and fixes two edge-mode bugs (reconnect backoff reset, steady-state read deadline). The security foundation from v0.2.0 is all present: Ed25519 per-client authentication, key enrollment, Argon2id token hashing, a read-only MCP server, Prometheus metrics, structured audit logging, and hardened CI/supply-chain infrastructure. See CHANGELOG.md for full release notes.

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

    subgraph hostA ["Remote host A"]
        direction LR
        LA["Portwing<br/>(agent)"]
        SGA["sockguard<br/>(socket filter)"]
        DA["Docker Engine"]
        LA -- "filtered socket" --> SGA --> DA
    end

    subgraph hostB ["Remote host B"]
        direction LR
        LB["Portwing<br/>(agent)"]
        SGB["sockguard<br/>(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 Portwing agent over HTTP/HTTPS (it initiates; Portwing serves). Each agent reaches the Docker Engine only through a sockguard socket filter. Outbound edge mode (the agent dialing the controller, for hosts with no inbound port) is usable end-to-end as of Drydock 1.5 β€” see Connection Modes.

πŸš€ Quick Start

The strongest posture combines three controls: sockguard (socket-level request filtering so Portwing never touches the raw Docker socket directly), Ed25519 per-request authentication (signed requests, replay protection, no shared secrets), and a hardened container runtime (read_only, cap_drop: ALL, no-new-privileges, secrets-mounted tokens). Run Portwing in standard mode strictly behind a TLS reverse proxy β€” the Drydock controller connects inbound to it. (Outbound edge mode for hosts with no inbound port is usable end-to-end with Drydock 1.5.)

Step 1 β€” generate a token and pull the example:

openssl rand -hex 32 > portwing_token.txt
# Download the hardened compose file and its sockguard policy
curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/docker-compose.with-sockguard.yml
curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/sockguard.yaml

Step 2 β€” start the hardened stack:

docker compose -f docker-compose.with-sockguard.yml up -d

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):

# Portwing + sockguard β€” two-layer defense.
#
# Sockguard sits between Portwing and the host's Docker socket and writes a
# filtered unix socket into a shared named volume. Portwing talks to that
# filtered socket instead of mounting /var/run/docker.sock directly, so even
# a fully compromised agent is constrained to the explicit API allowlist in
# sockguard.yaml.
#
# Generate a token first:
#   openssl rand -hex 32 > portwing_token.txt

services:
  sockguard:
    image: ghcr.io/codeswhat/sockguard:latest
    restart: unless-stopped
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./sockguard.yaml:/etc/sockguard/sockguard.yaml:ro
      - sockguard-socket:/var/run/sockguard
    environment:
      - SOCKGUARD_LISTEN_SOCKET=/var/run/sockguard/sockguard.sock

  portwing:
    image: ghcr.io/codeswhat/portwing:latest
    restart: unless-stopped
    depends_on:
      - sockguard
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp
    ports:
      - "3000:3000"
    volumes:
      - sockguard-socket:/var/run/sockguard:ro
      - portwing-stacks:/data/stacks
    environment:
      - DOCKER_SOCKET=/var/run/sockguard/sockguard.sock
      - TOKEN_FILE=/run/secrets/portwing_token
    secrets:
      - portwing_token

secrets:
  portwing_token:
    file: ./portwing_token.txt

volumes:
  sockguard-socket:
  portwing-stacks:

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. Use PRIVATE_KEY_FILE for signed edge-mode hellos.

Edge mode variant (outbound WebSocket β€” early access)

Early access. Edge mode is usable end-to-end as of the current release: Drydock 1.5 ships the /api/portwing/ws controller endpoint (Ed25519-only) and Portwing signs its hello with an Ed25519 key. Both Drydock 1.5 and the current Portwing release are pre-release; full exec robustness under load is still being hardened.

For hosts behind NAT or a firewall, examples/docker-compose.edge.yml has Portwing dial out to your Drydock controller's edge endpoint (DRYDOCK_URL + /api/portwing/ws); no port is published on the remote host.

services:
  portwing:
    image: ghcr.io/codeswhat/portwing:latest
    restart: unless-stopped
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - portwing-stacks:/data/stacks
    environment:
      - DRYDOCK_URL=https://drydock.example.com
      - TOKEN_FILE=/run/secrets/portwing_token
      - AGENT_NAME=edge-host-01
      # Key-based hello instead of a shared token:
      #   portwing keygen  β†’  PRIVATE_KEY_FILE=/run/secrets/portwing_key
    secrets:
      - portwing_token

secrets:
  portwing_token:
    file: ./portwing_token.txt

volumes:
  portwing-stacks:
Quick start (evaluation only β€” not for production)

This is for trying Portwing out locally. Environment-variable tokens are visible in docker inspect and process listings. Do not use in production β€” use the hardened deployment above instead.

docker run -d \
  --name portwing \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -p 3000:3000 \
  -e TOKEN=$(openssl rand -hex 24) \
  ghcr.io/codeswhat/portwing:latest

Without TOKEN (or TOKEN_HASH/AUTHORIZED_KEYS) the API is unauthenticated β€” anyone who can reach the port controls your Docker daemon.

Binary install (install.sh)
curl -fsSL https://raw.githubusercontent.com/codeswhat/portwing/main/scripts/install.sh | bash

πŸ†• Recent Updates

Latest release highlights
  • v0.3.0 shipped on 2026-06-15 β€” startup banner, Lookoutβ†’Portwing rename completed, GoReleaser dockers_v2 migration, and two edge-mode bug fixes (reconnect backoff reset, steady-state read deadline). See CHANGELOG.md.
  • v0.2.0 shipped on 2026-06-12 β€” 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.
  • 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, plus agent request metrics: request totals by method/code (portwing_http_requests_total), a request-duration histogram (portwing_http_request_duration_seconds), in-flight gauge (portwing_http_requests_in_flight), auth-failure counter (portwing_auth_failures_total), and rate-limited counter (portwing_rate_limited_total).
  • 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).
  • Hardened CI & supply chain β€” 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, and cosign keyless signing + CycloneDX SBOM + SLSA provenance on every release.
  • v0.1.0 shipped on 2025-06-01 β€” initial release: transparent Docker API proxy, Edge mode WebSocket tunnel, Drydock adapter, SSE event stream, token auth, rate limiting, multi-arch image.

See CHANGELOG.md for the full itemized history.


✨ Features

FeatureDescription
πŸ”„Connection ModesStandard mode (the Drydock controller connects inbound over HTTP/SSE) is the primary integration. Edge mode (agent dials out over WebSocket, for NAT/firewalled hosts) is usable end-to-end as of the current release with Drydock 1.5 (both pre-release).
🐳Transparent Docker API ProxyAll Docker Engine API paths forwarded to the local daemon β€” streaming endpoints, exec session hijacking, and long-lived connections included.
πŸ”‘Ed25519 Per-Client AuthenticationPer-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 HashingHash 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 ServerAI assistants connect to /_portwing/mcp (Streamable HTTP, protocol 2025-11-25). Read-only tools: list_containers, inspect_container, container_logs, host_metrics, container_stats. Env variable values are never transmitted.
πŸ“¦Container InventoryFull container metadata with dd.* label parsing and SSE broadcasting, including dd:watcher-snapshot events for Drydock compatibility.
πŸ“ˆPrometheus MetricsHost and per-container CPU/memory/network in cAdvisor-compatible format at /_portwing/metrics. Zero external dependencies.
πŸ“‹Audit LoggingStructured JSON of every API call, auth event, exec session, and Compose operation. Disabled by default (single nil check overhead when off).
πŸ–₯️Host MetricsCPU, memory, disk, network, and uptime collection.
⚑Interactive ExecTerminal sessions via WebSocket or HTTP hijack with 100 concurrent session cap.
πŸ—‚οΈDocker ComposeFull lifecycle management with security hardening β€” path traversal protection, env var denylist, service name injection prevention.
πŸ“‘SSE CompatibilityDrop-in replacement for existing Drydock agents, including dd:watcher-snapshot full inventory on connect.
✍️Signed Supply ChainCosign keyless signatures, CycloneDX SBOM, and SLSA provenance on every release. Verifiable without managing signing keys.
πŸ›‘οΈTwo-Layer DefensePair with sockguard so the agent never touches the raw Docker socket directly.
πŸͺΆMinimal FootprintStatic Go binary, ~10 MB Wolfi (Chainguard) container image. CGO disabled, stripped, no external runtime dependencies.
πŸ”ŒStandalone ModeADAPTER=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.

TOKEN=$(openssl rand -hex 32)
docker run -d --name portwing \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e TOKEN="$TOKEN" \
  -p 3000:3000 \
  ghcr.io/codeswhat/portwing:latest
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:

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

# /etc/portwing/authorized_keys  (mode 0600)
ed25519 AAAA... my-platform:prod

Start the agent with Ed25519 auth:

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 \
  ghcr.io/codeswhat/portwing:latest

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.


πŸ”Œ 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 β€” early access

Portwing initiates an outbound WebSocket to the controller's edge endpoint (DRYDOCK_URL + /api/portwing/ws) for hosts with no inbound port. Both sides are implemented β€” Drydock 1.5 ships the controller endpoint and Portwing signs an Ed25519 hello β€” so edge mode is usable end-to-end as of the current release. Both Drydock 1.5 and the current Portwing release are pre-release; full exec robustness under load is still being hardened. The endpoint is Ed25519-only: set PRIVATE_KEY_FILE and register the public key with Drydock.

  • Set when DRYDOCK_URL is configured along with TOKEN, AUTHORIZED_KEYS, or PRIVATE_KEY_FILE
  • Targets hosts behind NAT, firewalls, and dynamic IPs
  • Auto-reconnect with exponential backoff + jitter; signed hello via PRIVATE_KEY_FILE
DRYDOCK_URL set + (TOKEN or AUTHORIZED_KEYS or PRIVATE_KEY_FILE) set  β†’  Edge Mode (outbound WebSocket)
Otherwise                                                              β†’  Standard Mode (inbound HTTP server)

πŸ–₯️ 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.

docker run -d \
  --name portwing \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e ADAPTER=generic \
  -e TOKEN=my-secret \
  -p 3000:3000 \
  ghcr.io/codeswhat/portwing:latest

Endpoints

EndpointDescription
GET /api/v1/versionAgent version, protocol info
GET /api/v1/containersCached container inventory
GET /api/v1/containers/{id}/logsContainer logs (tail, since, until, follow)
GET /api/v1/eventsSSE stream of Docker lifecycle events

curl examples

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:

{
  "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

VariableDefaultDescription
DRYDOCK_URL--WebSocket URL for Edge mode (wss://...)
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_SECONDS60Maximum allowed clock skew for Ed25519 request timestamps
NONCE_LRU_SIZE10000In-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_VERIFYfalseSkip TLS verification (testing only)
PORT3000HTTP server port
BIND_ADDRESS0.0.0.0Bind address
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

Docker

VariableDefaultDescription
DOCKER_SOCKETAuto-detectDocker socket path
DOCKER_HOST--Docker TCP host (alternative)
STACKS_DIR/data/stacksCompose stack file directory

Agent Identity

VariableDefaultDescription
AGENT_IDUUID v4Unique agent identifier
AGENT_NAMEHostnameHuman-readable name

Operational

VariableDefaultDescription
HEARTBEAT_INTERVAL30Ping interval (seconds)
WELCOME_TIMEOUT30Seconds to await the Drydock welcome message in edge mode
REQUEST_TIMEOUT30Docker API request timeout (seconds)
RECONNECT_DELAY1Initial reconnect delay (seconds)
MAX_RECONNECT_DELAY60Max reconnect delay (seconds)
LOG_LEVELinfodebug, info, warn, error
SKIP_DF_COLLECTION--Disable disk metrics
AUDIT_LOG--Audit log sink: stdout, stderr, or a file path; unset disables auditing
AUDIT_BUFFER_SIZE256In-memory audit records retained for GET /_portwing/audit; 0 disables. Independent of AUDIT_LOG.

Adapter

VariableDefaultDescription
ADAPTERdrydockAdapter to use: drydock (Drydock-compatible) or generic (standalone REST/SSE)

Drydock Compatibility

VariableDefaultDescription
DD_AGENT_SECRET--Drydock agent secret token
DD_AGENT_SECRET_FILE--Drydock agent secret token file
DD_POLL_INTERVAL300Container inventory refresh (seconds)

πŸ“‘ API Reference

Health, agent, MCP, Drydock-compatible, and proxy endpoints

Health Endpoints

EndpointMethodAuthDescription
/healthGETNoSimple health check β€” {"status":"ok"}
/_portwing/healthGETNoHealth check + Docker connectivity

/_portwing/health returns HTTP 503 when the Docker daemon is unreachable. Both endpoints are unauthenticated and safe to use for load-balancer probes and Docker HEALTHCHECK instructions.

Agent Endpoints

EndpointMethodAuthDescription
/_portwing/infoGETYesAgent version, mode, capabilities
/_portwing/composePOSTYesDocker Compose operations
/_portwing/metricsGETYesPrometheus metrics (agent-scoped)
/metricsGETYesPrometheus metrics (Drydock agent secret)
/_portwing/auditGETYesRecent audit records (JSON, newest-first; ?limit=N)
/_portwing/mcpPOSTYesMCP server (JSON-RPC 2.0, protocol 2025-11-25)

MCP β€” AI Assistant Integration

Portwing exposes a read-only Model Context Protocol 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 2025-11-25 β€” Streamable HTTP, stateless single-request mode, Content-Type: application/json.

Available tools:

ToolDescription
list_containersAll containers β€” id, names, image, state, status, labels
inspect_container(id)State, image, env-var count (values never exposed), mounts, networks, restart policy
container_logs(id, tail)Last N lines of stdout/stderr (max 500)
host_metricsCPU, memory, disk, network, uptime snapshot
container_stats(id)One-shot CPU/memory/network stats for a container

Credential hygiene: inspect_container returns only the count of environment variables β€” values are never transmitted, preventing accidental secret leakage.

Add to Claude Desktop (claude_desktop_config.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)

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)

{
  "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

EndpointMethodDescription
/api/eventsGETSSE event stream (dd:ack, container events)
/api/containersGETContainer inventory
/api/containers/:id/logsGETContainer logs
/api/containers/:idDELETERemove container
/api/watchersGETWatcher components
/api/triggersGETTrigger 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). Both require bearer auth. In addition to build/host/per-container series, both endpoints also expose agent request metrics: portwing_http_requests_total{method,code}, portwing_http_request_duration_seconds (histogram), portwing_http_requests_in_flight (gauge), portwing_auth_failures_total{reason}, and portwing_rate_limited_total.

Prometheus scrape config:

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

πŸ”‘ 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.

# Generate a strong token
TOKEN=$(openssl rand -hex 32)
docker run -e TOKEN="$TOKEN" ... ghcr.io/codeswhat/portwing:latest

File-based token (production)

TOKEN=$(openssl rand -hex 32)
printf '%s' "$TOKEN" > /run/secrets/portwing-token
chmod 600 /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:latest

Hash-at-rest with TOKEN_HASH

Store only an Argon2id hash so the plaintext token never appears in env dumps or config files:

# 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$<salt>$<hash>

# Use the hash instead of the plaintext
docker run -e TOKEN_HASH="$HASH" ... ghcr.io/codeswhat/portwing:latest

Or write the hash to a file and use TOKEN_HASH_FILE:

printf '%s' "$TOKEN" | portwing hash-token > /run/secrets/portwing-token-hash
docker run -e TOKEN_HASH_FILE=/run/secrets/portwing-token-hash ...

βœ… Verify a Release

Cosign verification for checksums and container images

Portwing releases are signed with Sigstore cosign via GitHub Actions keyless signing. Checksums and container images can be verified without managing signing keys.

Verify the checksums file

TAG=v0.1.0

cosign verify-blob \
  --certificate-identity-regexp "https://github.com/CodesWhat/portwing/.github/workflows/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  --bundle "checksums.txt.bundle" \
  "checksums.txt"

Verify the container image

TAG=v0.1.0

cosign verify \
  --certificate-identity-regexp "https://github.com/CodesWhat/portwing/.github/workflows/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "ghcr.io/codeswhat/portwing:${TAG}"

SBOM

Each release includes a CycloneDX SBOM attached as a release asset (portwing-${TAG}-sbom.cdx.json). Download and inspect it with any CycloneDX-compatible tool, or verify it with cosign the same way as the checksums file.


πŸ›‘οΈ Security

Security model summary
  • Authentication: Token-based with timing-safe comparison (crypto/subtle); hash-at-rest via TOKEN_HASH (Argon2id); Ed25519 per-client keypairs with per-request signatures and replay protection
  • Rate Limiting: 10 failed auth attempts per IP per minute
  • TLS: TLS 1.2+ with modern AEAD cipher suites
  • Compose Security: 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 for the full citable spec and CVE mapping.


πŸ“‹ Audit Logging

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

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

Auditing is disabled by default (AUDIT_LOG unset). When disabled the overhead is a single nil pointer check per request. Separately, setting AUDIT_BUFFER_SIZE (default 256) keeps the most recent audit records in an in-memory ring buffer for pull-based retrieval at GET /_portwing/audit (auth required), so a controller can read the audit trail without host file access. The buffer is independent of AUDIT_LOG and works even when the slog sink is off.

Events

eventTriggered when
api_requestAny authenticated API call completes
auth_failureAn invalid token is presented
rate_limitedAn IP is blocked by the rate limiter
compose_opA Docker Compose operation runs
exec_startAn interactive exec tunnel opens

Sample JSON lines

{"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:

{"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:

{"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"}

πŸ“– Documentation

ResourceLink
Security Modeldocs/security-model.md
Ed25519 Auth Designdocs/design/ed25519-auth.md
Watchtower Migrationdocs/migrating-from-watchtower.md
Drydock Integrationdocs/drydock-integration.md
OpenAPI Specapi/openapi.yaml
ChangelogCHANGELOG.md
ContributingCONTRIBUTING.md
Code of ConductCODE_OF_CONDUCT.md
Security PolicySECURITY.md
ReleasingRELEASING.md
Examplesexamples/
IssuesGitHub Issues
DiscussionsGitHub Discussions

Star History Chart

SemVer Conventional Commits Keep a Changelog

Built With

Go 1.26 gorilla/websocket google/uuid golang.org/x/crypto Sigstore Wolfi Docker GoReleaser

Community & Support

Issues, ideas, and pull requests are welcome. Start with CONTRIBUTING.md, use SECURITY.md for private vulnerability disclosure, and use GitHub Discussions for design questions.

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 section above.

AGPL-3.0 License

Built by CodesWhat

Ko-fi Buy Me a Coffee Sponsor

Back to top