Skip to content

API Reference

Complete HTTP API reference for the Portwing agent, grouped by function with authentication requirements and curl examples.

The OpenAPI 3.1 specification at api/openapi.yaml in the repository is the authoritative source of truth. This page is derived from that spec. If anything conflicts, the spec wins.

Portwing exposes an HTTP API on port 3000 by default (configurable via PORT). All requests are served over plain HTTP or TLS 1.2+ depending on whether TLS_CERT / TLS_KEY are configured. See Authentication for token and Ed25519 key setup, and Connection Modes for how Standard mode and Edge mode differ.


Authentication

Protected endpoints require the agent token in one of these headers (checked in order):

HeaderNotes
Authorization: Bearer <token>Standard, preferred
X-Portwing-TokenAlternative header
X-Dd-Agent-SecretDrydock agent secret header

Alternatively, per-request Ed25519 signatures are accepted when the X-Portwing-Signature header is present. Current clients send X-Portwing-Signature-Version: 2 and sign the escaped path plus exact raw query string. See Authentication for the full signing protocol.

Rate limiting: 10 failed auth attempts per IP per rolling minute. Exceeding the limit returns HTTP 429 until the window resets.


Health and Info

No authentication is required for the health endpoints. Use them for Docker HEALTHCHECK instructions and load-balancer probes.

MethodPathAuthDescription
GET/healthNoneProcess liveness. Always HTTP 200 while the agent is running; does not probe dependencies.
GET/readyNoneReadiness. Requires Docker in standard mode and Docker plus the controller in edge mode.
GET/_portwing/healthNoneCompatibility alias for /ready.
GET/_portwing/infoRequiredReturns agent version, Docker version, connection mode, uptime, hostname, agent ID, and capability list.

Response shapes

GET /health

{
  "status": "ok",
  "live": true,
  "ready": false,
  "mode": "standard",
  "version": "0.9.1",
  "uptimeSeconds": 3842.7,
  "docker": "unknown",
  "controller": "not_applicable"
}

GET /ready and GET /_portwing/health

{
  "status": "healthy",
  "live": true,
  "ready": true,
  "mode": "standard",
  "version": "0.9.1",
  "uptimeSeconds": 3842.7,
  "docker": "connected",
  "controller": "not_applicable"
}

Returns HTTP 503 with "ready":false when Docker is unreachable or, in edge mode, when the controller WebSocket is disconnected.

GET /_portwing/info

{
  "version": "0.9.1",
  "dockerVersion": "27.0.3",
  "mode": "standard",
  "uptime": "2h34m56.789s",
  "hostname": "my-server",
  "agentId": "550e8400-e29b-41d4-a716-446655440000",
  "agentName": "my-server",
  "capabilities": ["compose", "exec", "metrics", "events", "dd:container-sync", "dd:logs"]
}

Unsupported legacy dd:watch and dd:trigger capabilities are absent. The watcher descriptor declares controller ownership and the trigger list remains empty; their reserved wire message types are not feature advertisements.

curl example

# Quick liveness probe
curl -s http://localhost:3000/health

# Docker connectivity check
curl -s http://localhost:3000/ready | jq .

# Agent metadata
curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/_portwing/info | jq .

Metrics

Prometheus-format host and agent metrics. See Observability for scrape configuration guidance.

MethodPathAuthDescription
GET/_portwing/metricsRequiredAgent metrics only, prefixed portwing_. Preferred for targeted scraping.
GET/metricsRequiredAlias for /_portwing/metrics. Provided for compatibility with default Prometheus scrape configs.

Both endpoints return text/plain in Prometheus exposition format 0.0.4. Standard mode requires auth. Edge mode exposes /metrics on its limited local operations listener without auth, so keep that listener on a private network.

curl example

curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/_portwing/metrics

Sample output:

# HELP portwing_host_cpu_usage_percent Host CPU usage percentage.
# TYPE portwing_host_cpu_usage_percent gauge
portwing_host_cpu_usage_percent 23.5
# HELP portwing_host_memory_used_bytes Host memory used in bytes.
# TYPE portwing_host_memory_used_bytes gauge
portwing_host_memory_used_bytes 4294967296

Audit export

Standard mode exposes both a human/operator JSON view and a polling-friendly NDJSON export; both require authentication. Edge mode exposes only the NDJSON export on its local operations listener, without inbound authentication.

MethodPathAuthDescription
GET/_portwing/auditRequiredRecent ring-buffer records, newest first, wrapped in {records,count}.
GET/_portwing/audit/exportStandard: required; Edge: noneCursor-based NDJSON records, oldest first.

Treat the Edge operations listener as a private-operations trust boundary. Anyone who can reach it can read retained audit records, which can contain client addresses, request paths, stack names, and container identifiers. Do not expose it through a public Service, ingress, host port, or shared network.

For initial export, omit cursor. After consuming the response, persist X-Portwing-Next-Cursor and pass it on the next poll:

curl -sS -D headers.txt \
  -H "Authorization: Bearer $TOKEN" \
  "https://localhost:3000/_portwing/audit/export?cursor=42&limit=500"

The response also includes X-Portwing-Oldest-Cursor, X-Portwing-Latest-Cursor, and X-Portwing-Record-Count. HTTP 409 means the cursor was overwritten or belongs to a previous agent process; surface the gap and resume from the reset cursor in X-Portwing-Next-Cursor.

For Edge mode, poll from the same private container or pod network and omit the Authorization header:

curl -sS -D headers.txt \
  "http://portwing-edge:3000/_portwing/audit/export?cursor=42&limit=500"

See Audit Logging for the stable sink/export schemas and shipping guidance.


Containers and Events (Drydock-compatible)

These endpoints are used by the Drydock controller when Portwing runs in Standard mode. They share the /api/ prefix and all require authentication.

MethodPathAuthDescription
GET/api/containersRequiredFull container inventory with dd.* label metadata applied.
GET/api/containers/{id}/logsRequiredDemuxed container logs. Supports tail, since, until, follow query params.
DELETE/api/containers/{id}RequiredForce-remove a container. Returns HTTP 204 on success.
GET/api/eventsRequiredSSE stream of container lifecycle events in Drydock envelope format.
GET/api/watchersRequiredWatcher component descriptors registered with this agent.
GET/api/triggersRequiredTrigger component descriptors registered with this agent.
POST/api/watchers/{type}/{name}RequiredTrigger watcher poll — not implemented in v1.0, returns HTTP 501.
POST/api/watchers/{type}/{name}/container/{id}RequiredCheck single container — not implemented in v1.0, returns HTTP 501.
POST/api/triggers/{type}/{name}RequiredExecute trigger — not implemented in v1.0, returns HTTP 501.
POST/api/triggers/{type}/{name}/batchRequiredExecute batch trigger — not implemented in v1.0, returns HTTP 501.

Registry checks (/api/watchers/.../container/...) are performed by the Drydock controller, not the agent. The stub endpoints exist for API-compatibility and always return HTTP 501.

Log query parameters

ParameterTypeDescription
tailintegerNumber of lines from the end to return.
sincestringShow logs since this RFC 3339 timestamp or relative duration (e.g. 1h).
untilstringShow logs before this timestamp or duration.
followstring1 or true to stream logs in real time (chunked transfer encoding).

SSE event types (/api/events)

TypeDescription
dd:ackInitial handshake — agent metadata and current container counts.
dd:container-addedNew container discovered.
dd:container-updatedContainer state or metadata changed.
dd:container-removedContainer removed.

curl examples

# Container inventory
curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/containers | jq .

# Last 100 lines of logs for a container
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/containers/my-container/logs?tail=100"

# Follow logs live
curl -sN -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/containers/my-container/logs?follow=1"

# SSE event stream
curl -sN -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/events

Generic Adapter (/api/v1/)

Available when ADAPTER=generic. These endpoints expose container management without requiring a Drydock controller. See Standalone Mode for setup instructions.

MethodPathAuthDescription
GET/api/v1/versionRequiredAgent version, protocol name/version, and active adapter name.
GET/api/v1/containersRequiredCached container inventory from the Docker daemon.
GET/api/v1/containers/{id}/logsRequiredDemuxed container logs. Same query parameters as the Drydock-compatible endpoint.
GET/api/v1/eventsRequiredSSE stream of Docker container lifecycle events in a stable generic envelope.

/api/v1/version response

{
  "agentVersion": "0.9.1",
  "protocolName": "portwing",
  "protocolVersion": "1.0",
  "adapter": "generic"
}

Generic event envelope (/api/v1/events)

Each SSE frame carries a JSON object:

{
  "ts": "2026-06-11T10:00:00Z",
  "type": "container",
  "action": "start",
  "containerId": "abc123def456",
  "name": "my-container",
  "image": "nginx:latest",
  "labels": { "app": "web" }
}

Observed action values: create, start, stop, die, kill, restart, pause, unpause, destroy, rename, update, oom, health_status.

A comment heartbeat line (: heartbeat) is written every 30 seconds to keep the connection alive through proxies.

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
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/v1/containers/my-container/logs?tail=50"

# Stream live logs
curl -sN -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/v1/containers/my-container/logs?follow=1"

# SSE event stream
curl -sN -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/v1/events

Compose

Execute Docker Compose sub-commands within the managed stacks directory (STACKS_DIR, default /data/stacks).

MethodPathAuthDescription
POST/_portwing/composeRequiredRun a Compose operation against a named stack.

Request body

{
  "operation": "up",
  "stackName": "myapp",
  "stackDir": "myapp/v2",
  "services": ["web", "worker"],
  "build": false,
  "forceRecreate": false,
  "removeVolumes": false,
  "noDeps": false,
  "tail": 100,
  "envVars": {
    "APP_PORT": "8080"
  },
  "files": {
    "docker-compose.yml": "version: '3'\nservices:\n  web:\n    image: nginx\n"
  },
  "registryAuth": {
    "server": "ghcr.io",
    "username": "myuser",
    "password": "ghp_..."
  }
}

operation is required and must be one of: up, down, pull, ps, logs, restart, stop, start. stackName is required.

Response

{
  "success": true,
  "output": "Container myapp-web-1  Started\n"
}

On failure, success is false and the error field contains the exit message.

Security controls

The agent applies these checks before running any Compose command:

  • Stack path is resolved to an absolute path and verified to stay within STACKS_DIR (path-traversal protection).
  • envVars keys must match ^[a-zA-Z_][a-zA-Z0-9_]*$ and are checked against a denylist that includes LD_PRELOAD, PATH, DOCKER_HOST, HOME, SHELL, and related variables.
  • Service names must not start with - (flag-injection prevention).
  • Registry credentials are passed via --password-stdin and never appear in the process argument list.
  • Files written into the stack directory are path-resolved and verified to remain within the stacks directory.

curl example

# Bring up a stack
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"operation":"up","stackName":"myapp"}' \
  http://localhost:3000/_portwing/compose | jq .

# Tear down a stack and remove volumes
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"operation":"down","stackName":"myapp","removeVolumes":true}' \
  http://localhost:3000/_portwing/compose | jq .

Docker API Passthrough

Any path not matched by a named Portwing route is transparently proxied to the local Docker daemon over its Unix socket. This gives Portwing clients the full Docker Engine API surface without additional tooling.

MethodPathAuthDescription
GET / POST / DELETE / .../v1.44/{path} (representative)RequiredProxy to Docker Engine API. All HTTP methods are forwarded.

v1.44 is the fallback API version. The agent negotiates the actual version with the Docker daemon on startup (e.g. v1.47). Clients may also omit the version prefix — the agent prepends it automatically.

Streaming endpoints (/logs, /attach, /exec/.../start, /events, /build, /images/create, /images/push) are detected by path and forwarded with flush-on-read streaming. Interactive exec sessions using Upgrade: tcp or Upgrade: websocket are handled via HTTP hijacking.

The complete Docker Engine API is documented at https://docs.docker.com/engine/api/v1.44/.

curl example

# List running containers via Docker proxy
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/v1.44/containers/json" | jq .

# Pull an image
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/v1.44/images/create?fromImage=nginx&tag=latest"

Enrollment

One-shot bootstrap endpoint for registering an Ed25519 public key (Model C authentication). See Authentication for the full key management workflow.

MethodPathAuthDescription
POST/api/portwing/enrollEnrollment tokenRegister an Ed25519 public key. Token is burned after first successful use.

This endpoint is only available when ENROLLMENT_TOKEN (and AUTHORIZED_KEYS) are configured. The endpoint is reachable without a prior agent token — the enrollment token is the credential. Its JSON body is limited to 64 KiB; oversized bodies return HTTP 413. Malformed and oversized requests feed a separate per-IP abuse limiter, while bad credentials feed the authentication-failure limiter.

Request body

{
  "enrollment_token": "a3f2b1c9d8e7f6a4b5c6d7e8f9a0b1c2",
  "public_key": "AAAAC3NzaC1lZDI1NTE5AAAAIB3..."
}

public_key must be standard base64-encoded raw 32-byte Ed25519 public key material.

Response

{
  "key_id": "4a3f2b1c9d8e7f6a",
  "comment": "enrolled:4a3f2b1c9d8e7f6a"
}

key_id is hex(SHA-256(pubkey)[:8]) — the value to supply in X-Portwing-Key-ID headers for subsequent signed requests.

After enrollment, the key is immediately active. The enrollment token is burned; subsequent calls return HTTP 401 until the agent restarts with a new ENROLLMENT_TOKEN.

curl example

curl -s -X POST \
  -H "Content-Type: application/json" \
  -d "{\"enrollment_token\":\"$ENROLL_TOKEN\",\"public_key\":\"$PUBKEY_B64\"}" \
  http://localhost:3000/api/portwing/enroll | jq .

HTTP status codes

CodeMeaning
200Success
204Success, no body (container delete)
400Bad request — invalid body or parameters
401Missing, invalid, or burned token
405Method not allowed
429Rate limit exceeded (10 failures/min/IP)
500Internal error
501Not implemented (stub Drydock endpoints)
502Docker daemon unreachable or returned an error (proxy)
503Docker daemon unreachable (health check)