REST API

Sōzune exposes a REST API to manage entrypoints on the fly, without restarting. The API also surfaces real-time diagnostics, backend health, and the identity of the currently authenticated user — everything the dashboard needs to drive an interactive UI.

Configuration

config.yaml:

api:
  enabled: true
  listen_address: "127.0.0.1:3035"
  users:
    - name: admin
      hash: "b630f5d579dfef28c45ddf5e3c7a65f09ebca4d5b064a70c4203578c8667fdeb"
      role: admin
    - name: dashboard
      hash: "7d4cab0d7c8a5e9eef83b7d306b4cb6dad27b3aaf7df9e0db18f78f9efb1ee43"
      role: read-only
  cors_origins:
    - "https://dashboard.example.com"

The API refuses to start when users is empty. There is no anonymous mode.

Authentication

The API uses HTTP Basic. Each user has a name, a hash (hex of sha256(password)), and a role.

Generate a hash with:

echo -n "your-password" | sha256sum

Then call the API with the password — sōzune hashes it on receive and compares in constant time:

curl -u admin:your-password http://localhost:3035/entrypoints

The hash format matches what sōzune accepts in route-level basic auth (sozune.http.<svc>.auth.basic), so the same generation step works on both sides.

Roles

RoleGET / HEAD / OPTIONSPOST / PUT / DELETE
admin (default)yesyes
read-onlyyes403 Forbidden

role can be omitted in config.yaml; it defaults to admin.

Read-only users can still read every endpoint, including /diagnostics and /me. Write attempts return 403 with a JSON error body:

{ "error": "read-only role cannot perform this operation" }

Securing the API on a network

HTTP Basic over plaintext HTTP sends the password in the clear on every request. It is only safe when the connection is encrypted.

The default listen_address: "127.0.0.1:3035" keeps the API local-only — fine for CLI use from the same host, never expose it on 0.0.0.0 without TLS in front.

To expose it remotely, put it behind TLS:

  • Behind sōzune itself — declare an entrypoint that points at 127.0.0.1:3035 with tls: true and an ACME-issued certificate.
  • Behind another reverse proxy that already terminates TLS (nginx, Caddy, an ingress controller).

CORS

When cors_origins is empty, the API responds with Access-Control-Allow-Origin: * (every origin allowed). Set cors_origins to a list of explicit origins to restrict the browser-side calls — useful when the dashboard is served from a different domain than the API.

Allowed methods are GET, POST, PUT, DELETE, OPTIONS. Allowed headers: Authorization, Content-Type, Accept.

Common error responses

Every error response body is JSON with an error field:

StatusWhen
400 Bad RequestMalformed JSON in the request body
401 UnauthorizedMissing or invalid Authorization: Basic ... header. Response includes WWW-Authenticate: Basic realm="sozune".
403 ForbiddenAuthenticated but the role doesn't permit the operation (read-only writes, or attempting to mutate a provider-owned entrypoint)
404 Not FoundUnknown entrypoint id
415 Unsupported Media TypeContent-Type is missing or not application/json on a write
422 Unprocessable EntityJSON parsed but required fields are missing or the wrong type
500 Internal Server ErrorInternal state lock poisoned (unrecoverable; sōzune needs a restart)

Endpoints

GET /health

Liveness probe. No auth required.

curl http://localhost:3035/health
{ "status": "ok" }

Returns 200 OK as long as the API server can answer. It does not validate downstream state (worker reachability, provider connectivity).

GET /me

Returns the authenticated user's identity. The dashboard hits this on login to validate credentials and learn its role.

curl -u dashboard:your-password http://localhost:3035/me
{
  "name": "dashboard",
  "role": "read-only"
}

role is either "admin" or "read-only".

GET /entrypoints

Lists every entrypoint sōzune currently routes — from every provider (Docker, Podman, Swarm, Nomad, Kubernetes Ingress/Gateway API, HTTP, config file) plus those created through this API. Available to both roles.

curl -u admin:your-password http://localhost:3035/entrypoints

Response: a JSON array of entrypoint objects (see Entrypoint schema below). Each item also carries:

  • unhealthy_backends: list of "<address>:<port>" backend strings the health checker has marked unhealthy for this entrypoint
  • diagnostics: list of Diagnostic objects associated with this entrypoint, including runtime collision lints (W018)

GET /entrypoints/{id}

Fetches a single entrypoint by its id. Returns 404 if unknown. Available to both roles.

curl -u admin:your-password http://localhost:3035/entrypoints/http_api

Response shape identical to one element of GET /entrypoints — entrypoint object plus unhealthy_backends and diagnostics.

POST /entrypoints

Creates an entrypoint through the API. Admin only.

curl -X POST http://localhost:3035/entrypoints \
  -u admin:your-password \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-api",
    "backends": [
      { "address": "10.0.0.5", "port": 8080, "weight": 100 }
    ],
    "protocol": "Http",
    "config": {
      "hostnames": ["api.example.com"],
      "tls": true,
      "https_redirect": true,
      "priority": 0
    }
  }'

Required fields: name, backends, protocol, config. See CreateEntrypointRequest schema for the full field list.

Response: 201 Created with the created entrypoint in the body. The id is generated by sōzune; the entrypoint's source is "api".

PUT /entrypoints/{id}

Replaces an existing entrypoint created through the API. Admin only.

curl -X PUT http://localhost:3035/entrypoints/http_my-api \
  -u admin:your-password \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-api",
    "backends": [{ "address": "10.0.0.6", "port": 8080, "weight": 100 }],
    "protocol": "Http",
    "config": { "hostnames": ["api.example.com"], "tls": true, "priority": 0 }
  }'

Response: 200 OK with the updated entrypoint.

Returns 403 Forbidden if the entrypoint was discovered from a provider (Docker, Kubernetes, etc.) — those are read-only through the API. To change them, edit the source (container labels, Ingress/HTTPRoute spec, Nomad service tags…).

DELETE /entrypoints/{id}

Deletes an entrypoint. Admin only. Returns 204 No Content on success.

curl -X DELETE -u admin:your-password http://localhost:3035/entrypoints/http_my-api

Same 403 Forbidden rule as PUT: provider-owned entrypoints cannot be deleted through the API.

GET /providers

Snapshot of every provider sōzune knows about, with its enabled flag and the number of entrypoints it currently owns in the storage. Useful for a dashboard "what's wired up?" overview. Available to both roles.

curl -u admin:your-password http://localhost:3035/providers
{
  "providers": [
    { "name": "docker",     "enabled": true,  "configured": true,  "entrypoint_count": 5 },
    { "name": "podman",     "enabled": false, "configured": false, "entrypoint_count": 0 },
    { "name": "swarm",      "enabled": false, "configured": false, "entrypoint_count": 0 },
    { "name": "kubernetes", "enabled": true,  "configured": true,  "entrypoint_count": 12 },
    { "name": "nomad",      "enabled": false, "configured": false, "entrypoint_count": 0 },
    { "name": "http",       "enabled": false, "configured": false, "entrypoint_count": 0 },
    { "name": "config",     "enabled": true,  "configured": true,  "entrypoint_count": 2 }
  ]
}
  • name: identifier matching entrypoint.source for entrypoints emitted by this provider
  • configured: the provider block exists in config.yaml (truthy when providers.<name> is present, regardless of enabled)
  • enabled: the provider's enabled flag from config.yaml — only enabled providers are actually running and contributing entrypoints
  • entrypoint_count: live count of entrypoints in storage whose source matches this provider name

The list always contains every known provider, even when not configured, so the dashboard can render "configure me" rows next to inactive providers.

GET /diagnostics

Snapshot of every diagnostic sōzune has computed: per-candidate diagnostics from the parser, plus global lints (e.g. W015 ACME enabled but no tls=true) and runtime collision lints (W018). Available to both roles.

curl -u admin:your-password http://localhost:3035/diagnostics
{
  "total": 3,
  "global": [
    {
      "code": "W015",
      "severity": "warn",
      "message": "ACME enabled but no entrypoint declares tls=true",
      "hint": "set tls=true on at least one HTTP entrypoint to enable certificate provisioning"
    }
  ],
  "items": [
    {
      "candidate_id": "/sozune-test-app",
      "diagnostics": [
        {
          "code": "W001",
          "severity": "warn",
          "label": "sozune.http.app.port",
          "value": "abc",
          "message": "invalid port value, falling back to default",
          "hint": "use a positive integer between 1 and 65535"
        }
      ]
    }
  ]
}
  • total: number of diagnostics across global + every items[*].diagnostics
  • global: cross-cutting diagnostics not tied to a single candidate
  • items: per-candidate diagnostics, sorted by candidate_id for stable ordering

The full diagnostic code reference is documented at sozune explain <CODE>.

Entrypoint schema

The canonical shape of an entrypoint as returned by GET /entrypoints, GET /entrypoints/{id}, POST, and PUT:

{
  "id": "http_my-api",                  // sōzune-generated, stable across reloads
  "name": "my-api",                     // user-supplied, used as the cluster name
  "protocol": "Http",                   // "Http" | "Tcp" | "Udp"
  "backends": [
    { "address": "10.0.0.5", "port": 8080, "weight": 100 }
  ],
  "source": "api",                      // "api" | "docker" | "swarm" | "kubernetes" | "nomad" | "http" | "config"
  "config": {
    "hostnames": ["api.example.com"],   // exact, wildcard (*.example.com), or regex (/[a-z]+.example.com/)
    "path": {                           // optional path matcher
      "rule_type": "Prefix",            // "Prefix" | "Exact" | "Regex"
      "value": "/v1"
    },
    "tls": true,                        // enable TLS termination (provisions an ACME cert)
    "strip_prefix": false,
    "add_prefix": null,                 // string, mutually exclusive with strip_prefix
    "https_redirect": true,
    "https_redirect_port": null,        // override 443
    "redirect": null,                   // "forward" | "permanent" | "unauthorized"
    "redirect_scheme": null,            // "use_same" | "use_http" | "use_https"
    "redirect_template": null,
    "www_authenticate": null,
    "priority": 0,                      // higher wins on rule collision
    "auth": null,                       // see below
    "forward_auth": null,               // see below
    "headers": [],                      // see below
    "backend_timeout": null,            // milliseconds
    "rate_limit": null,                 // see below
    "sticky_session": false,
    "compress": false,                  // zstd/br/gzip negotiated via Accept-Encoding
    "entrypoint": null,                 // TCP listener name (required for protocol=Tcp)
    "methods": []                       // ["GET", "POST", ...]; empty = any method
  },
  "unhealthy_backends": [],             // only on GET responses
  "diagnostics": []                     // only on GET responses
}

Sub-schemas

auth (basic auth on this route):

{
  "basic": [
    { "username": "alice", "password_hash": "<sha256-hex>" }
  ]
}

forward_auth:

{
  "address": "http://authelia:9091/api/verify",
  "response_headers": ["Remote-User", "Remote-Email", "Remote-Groups"],
  "trust_forward_header": false
}

headers (each item adds or replaces one header on the request, response, or both):

[
  { "name": "X-Powered-By", "value": "sozune", "direction": "response" }
]

direction is "request", "response", or "both". Defaults to "request".

rate_limit:

{ "average": 100, "burst": 50 }

average is requests per second; burst is the bucket size. burst < average disables the burst window.

CreateEntrypointRequest schema

POST and PUT accept the same body shape — the four required top-level fields:

FieldTypeDescription
namestringLogical service name. Becomes part of the generated id.
backendsarray of BackendAt least one backend; each has address (IPv4, IPv6, or hostname), port, and optional weight (defaults to 100)
protocol"Http" | "Tcp" | "Udp"Routing protocol. UDP is parsed but not yet wired in.
configEntrypointConfigAll routing options. See Entrypoint schema. Every field except hostnames and priority is optional and defaults to its zero value.

id and source are ignored if present in the request — sōzune assigns them.

Diagnostic schema

{
  "code": "W001",
  "severity": "error" | "warn" | "info",
  "message": "invalid port value, falling back to default",
  "label": "sozune.http.app.port",   // optional: the offending label name
  "value": "abc",                    // optional: the offending value
  "hint": "use a positive integer between 1 and 65535"  // optional remediation hint
}

Run sozune explain <CODE> for the full cause / effect / fix / example of each code.

Note on provider-owned entrypoints

Entrypoints discovered from a provider (Docker labels, Kubernetes Ingress/HTTPRoute, Nomad service tags, Swarm service labels, HTTP poll, config file) are read-only through the API. PUT and DELETE against them return 403 Forbidden. To change them, edit the source.

The source field on each entrypoint indicates which provider owns it — useful when the dashboard wants to render "edit through Docker" vs "edit through API" affordances.