Control API
The control API is a small private HTTP API. It is versioned under /v1, requires Authorization: Bearer <token> on every route, and is loopback-bound by default in the supplied Compose stack. PR0XTEUS_DISABLE_HOST_PORTS=true removes every host binding for a private Docker-network gateway such as the Tailscale sidecar. POST /v1/proxies allocates one proxy lease; GET /v1/proxies lists active exits. The shared path is intentional: POST is the state-changing collection action and GET is the read-only collection view.
For a safe manual request and real SOCKS5 and HTTP egress proofs, follow the complete setup walkthrough. The handler and pool state behind these routes are described in controller internals. The handlers live in api.go and cells.go.
POST /v1/proxies
Request exactly one selection method:
{
"country": "US",
"fallbackOk": true
}
{
"pool": "primary",
"excludeProxy": "socks5://lease-id:lease-secret@127.0.0.1:1080"
}
Fields:
country: ISO 3166-1 alpha-2 code resolved by local routing policy.pool: exact local logical pool name. It bypasses country routing.excludeProxy: optional previous SOCKS5 URL. The matching configuration is excluded while finding a replacement. A controller lease URL must still be valid; an expired or unknown lease, or a URL that is notsocks5://host:port, gets a400.fallbackOk: permits a configured fallback pool when the selected pool is exhausted or unavailable.
Requests must use Content-Type: application/json. Bodies are capped at 16 KiB, unknown fields are rejected, and a request with both or neither of country and pool gets a validation error. country must be two ASCII letters.
Successful response (200 OK):
{
"proxies": {
"socks5": "socks5://lease-id:lease-secret@127.0.0.1:1080",
"http": "http://lease-id:lease-secret@127.0.0.1:8080"
},
"pool": "primary",
"exitCountry": "US",
"expiresAt": "2026-01-01T00:15:00Z"
}
proxies.socks5 points at the controller’s SOCKS5 gateway and proxies.http points at its standard HTTP proxy. They carry one random short-lived username/password lease and route only to the exact cell selected for this allocation. Keep both URLs out of logs. A lease never silently switches to a replacement cell. exitIP is reserved optional metadata and is normally omitted; the controller does not make an external exit-IP lookup. Do not make correctness depend on it.
GET /v1/proxies
Returns the flattened inventory of currently active tunnels. state is one of spawning, hot, unhealthy or reaping. It is paginated with limit (default 100, maximum 1000) and offset (default 0). It is read-only: it never starts a cell and never issues a new lease.
{
"proxies": [
{
"pool": "primary",
"confName": "edge-a",
"state": "hot",
"exitCountry": "US",
"spawnedAt": "2026-01-01T00:00:00Z",
"healthyAt": "2026-01-01T00:00:05Z",
"lastUsedAt": "2026-01-01T00:01:00Z",
"lastURL": "socks5://lease-id:lease-secret@127.0.0.1:1080",
"lastURLExpiresAt": "2026-01-01T00:16:00Z",
"idleSeconds": 12.3
}
],
"limit": 100,
"offset": 0,
"total": 1
}
lastURL is omitted until an allocation has been made. It is a live credential, so this endpoint has the same bearer-token protection as allocation.
GET /v1/pools
Returns the authenticated operator view of configured pools and their current tunnel projections. It is paginated with limit (default 100, maximum 1000) and offset (default 0). Docker IDs and in-flight request bookkeeping deliberately do not cross this API boundary.
Typical response:
{
"pools": [
{
"name": "primary",
"region": "operator-defined",
"confCount": 2,
"recentlyFailed": 0,
"tunnel": {
"confName": "edge-a",
"proxyUrl": "socks5://pr0xteus-tunnel-primary-123:1080",
"state": "hot",
"exitCountry": "US"
}
}
],
"limit": 100,
"offset": 0,
"total": 1
}
This is an operator view, not the client-facing proxy inventory. proxyUrl is the cell’s internal SOCKS endpoint; use POST /v1/proxies for a client URL and GET /v1/proxies for allocation history.
GET /v1/cells
Lists a page of running cells with live traffic snapshots. It is paginated with limit (default 100, maximum 1000) and offset (default 0). Cells are discovered straight from Docker: the controller queries for containers carrying its own pr0xteus.parent.id label, so the source of truth for which cells exist and where they are is Docker, not in-memory state. Each cell’s traffic is then scraped on demand from its cellproxy /status control endpoint at the cell’s current IP on the private cell network. The view is keyed by the cell’s own Docker container ID so a specific cell can be inspected or destroyed. state is Docker’s own container state, such as running.
{
"cells": [
{
"containerId": "9f3c1a2b4d5e",
"parentId": "controller-host-id",
"pool": "primary",
"confName": "edge-a",
"state": "running",
"exitCountry": "US",
"createdAt": "2026-08-14T20:00:00Z",
"uptimeSeconds": 128.4,
"traffic": {
"requests": 42,
"bytesUp": 18234,
"bytesDown": 918273,
"active": 3,
"dialFailures": 0,
"destinations": [
{
"destination": "example.com:443",
"requests": 40,
"bytesUp": 18000,
"bytesDown": 900000,
"active": 3
}
]
}
}
],
"limit": 100,
"offset": 0,
"total": 1
}
traffic is omitted and a statusError string is set when the controller could not reach a cell’s control server, for example in the host-loopback smoke mode, where cells expose no control address. The destination breakdown is bounded and byte-ranked: each cell reports its top 50 destinations, so the response stays small under heavy fan-out.
GET /v1/cells/{containerID}
Returns the single cell with the given container ID, in the same shape as one entry of GET /v1/cells. Responds 404 when no tracked cell matches.
DELETE /v1/cells/{containerID}
Destroys a cell on demand: stops its container and clears its pool slot so the next request re-spawns. Responds 204 on success and 404 when no tracked cell matches. Only destroy a cell your own task allocated.
Errors
Errors use one JSON envelope with a fixed code and message. The body never carries Docker or WireGuard error text:
{
"code": "VALIDATION_FAILED",
"message": "Validation failed"
}
| Status | code | When |
|---|---|---|
400 | VALIDATION_FAILED | Invalid body or query, unknown pool, unmapped country with no default pool, bad excludeProxy. |
401 | UNAUTHORIZED | Missing or incorrect bearer token. |
404 | NOT_FOUND | No cell matches the container ID on a /v1/cells/{containerID} route. |
415 | UNSUPPORTED_CONTENT_TYPE | Missing or non-JSON Content-Type on POST /v1/proxies. |
503 | SERVICE_UNAVAILABLE | No usable tunnel could be selected or started. |
500 | INTERNAL_SERVER_ERROR | Unexpected controller failure, including an unavailable Docker discovery call. |
For a 500, inspect the controller logs without printing credentials or WireGuard configuration. See troubleshooting for common causes.
GET /healthz and GET /metrics live on the separate metrics listener (default :9091), not under /v1, and are intentionally not authenticated. Keep that listener private. Monitoring lists the metrics. With the optional Tailscale profile, the wrapper routes tailnet TCP port 9091 to this listener without publishing a host port.