Operator HTTP API
The /v1 surface served on operator_http_listen. Loopback by default, session-or-bearer authenticated, RBAC-gated.
portunus-server exposes its operator surface as an HTTP API on
operator_http_listen (loopback by default). Most /v1/* requests require
either a Web session cookie or an API bearer token.
Authentication
Web UI users authenticate through local password login:
POST /v1/auth/login
Host: 127.0.0.1:7080
Content-Type: application/json
{"user_id":"admin","password":"correct horse battery staple"}Successful login returns {"password_change_required": false} and sets an
HttpOnly portunus_session cookie. API clients and CLI automation continue
to use bearer credentials:
GET /v1/clients
Host: 127.0.0.1:7080
Authorization: Bearer T006-quickstart-tokenCookie-authenticated mutating requests (POST, PUT, PATCH, DELETE) must
include an exact same-origin Origin header, X-Portunus-CSRF: 1, and JSON
content type when a body is present. Bearer-token requests do not need the CSRF
header.
Responses on missing / invalid authentication:
| Status | Body | Cause |
|---|---|---|
401 unauthenticated | {"error":{"code":"unauthenticated","message":"..."}} | Missing / invalid bearer or Web session |
403 not_owner | {"error":{"code":"not_owner","message":"..."}} | Authenticated, but caller does not own the resource |
403 password_change_required | {"error":{"code":"password_change_required","message":"..."}} | Web session must change a temporary password first |
403 client_not_granted / port_outside_grant / protocol_not_granted | RBAC envelope rejection (see RBAC) | |
403 csrf_* | Cookie-authenticated write failed CSRF checks | |
429 rate_limited | Too many login, onboarding, or password-reset attempts | |
503 bootstrap_required | Server has no superadmin yet — complete Web onboarding |
Endpoints by area
Auth
| Method | Path | Notes |
|---|---|---|
GET | /v1/auth/status | Public status: { onboarding_required } |
POST | /v1/auth/onboarding | Public first-admin creation; requires setup token while no active superadmin exists |
POST | /v1/auth/login | Public local password login; sets portunus_session |
POST | /v1/auth/logout | Session logout; requires session cookie and CSRF headers |
Self
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/me | { user_id, role, display_name } for the caller (used by SPA AuthGate) |
POST | /v1/users/me/password | Change own password; body includes current_password, new_password, new_password_confirm |
Clients
Client-scoped routes address the client by its stable client_id (a ULID
assigned at enrollment). client_name is a free-form display label and is
not an identifier — duplicates are allowed and a rename leaves the id
intact. An unknown or malformed client_id returns 404 on every
client-scoped route (it never reveals whether a colliding name exists).
| Method | Path | Notes |
|---|---|---|
POST | /v1/client-enrollments | Create a one-time enrollment command for a new client. Body {name, address?, ttl_secs?}; name is free-form |
POST | /v1/clients/{client_id}/enrollment | Create a one-time re-enrollment command for an existing client |
GET | /v1/clients | List clients (each row carries client_id + client_name); superadmin sees all |
PUT | /v1/clients/{client_id} | Update the client's advertised address; body {address} |
PATCH | /v1/clients/{client_id}/name | Identity-safe rename; body {client_name}. The id and all dependent rows are untouched; a live session is not dropped |
POST | /v1/clients/{client_id}/revoke | Revoke + disconnect |
DELETE | /v1/clients/{client_id} | Delete a revoked client row |
Rules
| Method | Path | Notes |
|---|---|---|
POST | /v1/rules | Push a rule (single-target, multi-target, port-range, with caps, with SNI) |
GET | /v1/rules | List rules; non-superadmin sees only owned rules. Optional ?client=<name> filter |
GET | /v1/rules?owner=<user_id> | Superadmin filter by owner |
PUT | /v1/rules/{rule_id} | Hot-update rate_limit only; client, listen, protocol, and targets must stay unchanged |
DELETE | /v1/rules/{rule_id} | Drain + remove |
GET | /v1/rules/{rule_id}/stats | Aggregate stats |
GET | /v1/rules/{rule_id}/stats?per_port=true | Per-port detail (range rules) |
GET | /v1/rules/{rule_id}/stats?per_target=true | Per-target detail (multi-target rules) |
GET | /v1/rules/{rule_id}/stats/stream | SSE stream, 5-second cadence; accepts ?per_target=true (v0.6+) |
Users
| Method | Path | Notes |
|---|---|---|
POST | /v1/users | Add user (superadmin); accepts optional initial_password and password_change_required |
GET | /v1/users | List users (superadmin) |
GET | /v1/users/{id} | User detail |
DELETE | /v1/users/{id} | Cascading remove (superadmin) |
POST | /v1/users/{id}/password | Reset user password (superadmin); revokes sessions and API tokens by default |
Credentials
| Method | Path | Notes |
|---|---|---|
POST | /v1/users/{id}/credentials | Issue a credential |
GET | /v1/users/{id}/credentials | List |
DELETE | /v1/users/{id}/credentials/{cred_id} | Revoke |
POST | /v1/users/{id}/credentials/{cred_id}/rotate | Rotate (self or superadmin) |
Grants
| Method | Path | Notes |
|---|---|---|
POST | /v1/grants | Add grant (superadmin) |
GET | /v1/grants | List grants (superadmin) |
DELETE | /v1/grants/{id} | Cascading revoke |
Rate limiting (v0.11+)
Per-rule caps travel on the POST / PUT /v1/rules body (see rate_limit
above). The endpoints below set a per-(owner, client) envelope.
| Method | Path | Notes |
|---|---|---|
GET | /v1/clients/{client_id}/owners | List owners with rules under this client |
GET | /v1/clients/{client_id}/owners/{owner_id}/rate-limit | Read the per-owner envelope |
PUT | /v1/clients/{client_id}/owners/{owner_id}/rate-limit | Set the per-owner envelope |
DELETE | /v1/clients/{client_id}/owners/{owner_id}/rate-limit | Remove the per-owner envelope |
Traffic quotas (v0.13+)
Per-(user, client) monthly traffic quotas and historical traffic queries.
Traffic queries take from and to (Unix seconds, required) plus an optional
bucket (sample granularity).
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/{user_id}/quotas | List a user's quotas |
PUT | /v1/users/{user_id}/quotas/{client_id} | Set the monthly quota |
PATCH | /v1/users/{user_id}/quotas/{client_id} | Update monthly_bytes and/or clear period usage |
DELETE | /v1/users/{user_id}/quotas/{client_id} | Remove the quota |
GET | /v1/users/{user_id}/quotas/{client_id}/status | Current period usage vs. cap |
GET | /v1/users/{user_id}/traffic?from=<unix>&to=<unix>&bucket=<size> | Historical traffic for a user |
GET | /v1/clients/{client_id}/quotas | List a client's quotas |
GET | /v1/clients/{client_id}/traffic?from=<unix>&to=<unix>&bucket=<size> | Historical traffic for a client |
GET | /v1/traffic/global?from=<unix>&to=<unix>&bucket=<size> | Historical traffic across all rules |
Settings (v0.14+)
| Method | Path | Notes |
|---|---|---|
GET | /v1/settings/advertised-endpoint | Read the advertised-endpoint override + effective value (superadmin) |
PUT | /v1/settings/advertised-endpoint | Set/clear the override; host must be covered by the server cert SAN (superadmin) |
Audit (v0.6+)
| Method | Path | Notes |
|---|---|---|
GET | /v1/audit?limit=N&outcome=allow|deny | v0.7-shape JSON array (back-compat) |
GET | /v1/audit?since=<RFC3339>&until=<RFC3339>&cursor=<base64>&limit=N | v0.8 envelope {entries, next_cursor?, count} |
Metrics (v0.6+)
| Method | Path | Notes |
|---|---|---|
GET | /v1/metrics | RBAC-gated mirror of the standalone /metrics; superadmin only |
The standalone scraper-facing endpoint at metrics_listen:7081/metrics
is unchanged (Prometheus continues scraping without bearer).
Request bodies for the common write endpoints
The JSON shapes most automation needs. Unlisted fields are optional.
POST /v1/auth/onboarding
First-admin creation while no active superadmin exists. Requires the setup
token printed in the server logs.
{
"user_id": "admin",
"display_name": "Admin",
"password": "correct horse battery staple",
"password_confirm": "correct horse battery staple",
"setup_token": "<token from server logs>"
}Returns 201 with { user_id, display_name, role }. It does not set a
session cookie — call POST /v1/auth/login next before any authenticated
request.
POST /v1/auth/login
{ "user_id": "admin", "password": "correct horse battery staple" }Returns 200 { "password_change_required": false } and sets the HttpOnly
portunus_session cookie used by subsequent cookie-authenticated calls.
POST /v1/client-enrollments
{ "name": "edge-01", "address": "edge-01.example.com", "ttl_secs": 600 }name— the client name to enroll.address— the public host consumers of this client's forwarded ports will reach. It must be a bare host (DNS name or IP) without a port; ahost:portvalue is rejected with400 invalid_client_address.ttl_secs— optional code lifetime (defaults to the server's setting).
Returns { client_name, expires_at, command, uri }. Run the command (or
redeem uri) on the edge host. The server endpoint embedded in the URI comes
from the advertised endpoint, falling
back to 127.0.0.1:7443.
POST /v1/users/{id}/credentials
{ "label": "ops-cli" }Returns 201 { credential_id, user_id, token, label, created_at }. The
token is the bearer credential and is shown once — store it immediately.
Status code semantics
| Code | Meaning |
|---|---|
200 | Success (read) |
201 | Resource created |
204 | Success, no body |
400 | Validation error |
401 | unauthenticated |
403 | RBAC denial, CSRF denial, or password_change_required |
404 | Resource not found |
409 | Conflict (e.g. conflict.legacy_to_sni_unsupported) |
422 | Capability gate (e.g. multi_target_unsupported_by_client, sni_unsupported_by_client, rate_limit_unsupported_by_client) |
429 | Authentication throttle |
503 | bootstrap_required |
The full code → exit-code → HTTP-status mapping is frozen at v1; see
the per-spec contract docs in
specs/*/contracts/operator-api.md.
Example: push a rule with caps
curl -sS -X POST http://127.0.0.1:7080/v1/rules \
-H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"client": "edge-01",
"listen_port": 8443,
"targets": [
{"host": "primary.local", "port": 443, "priority": 1},
{"host": "backup.local", "port": 443, "priority": 2}
],
"protocol": "tcp",
"sni_pattern": "*.example.com",
"rate_limit": {
"bandwidth_in_bps": 1048576,
"concurrent_connections": 1000
}
}'