Admin API

Sockguard's optional admin endpoints — config dry-run validation and policy version introspection. Deploy on a dedicated listener to keep admin traffic off the Docker-API data plane.

Sockguard exposes two admin endpoints: POST /admin/validate runs a parse/validate/compile dry-run against a candidate YAML body without touching the running policy, and GET /admin/policy/version returns a snapshot of the currently active policy. Both are opt-in and disabled by default.

Enabling the admin API

admin:
  enabled: true
  path: /admin/validate                # POST endpoint; must start with /
  policy_version_path: /admin/policy/version  # GET endpoint; must start with /
  max_request_bytes: 524288            # 512 KiB hard cap on validate request bodies
  # mount_on: edge                     # names the listener carrying /admin/*; required with 2+ listeners

Both endpoints share the admin.enabled gate. When disabled, Sockguard never wires the admin layers into the handler chain, so a request to either path falls through to the normal rule evaluator and is denied per the active policy — 403 default-deny (no_matching_allow_rule) unless some other rule happens to match the path first. A call that carries a form Content-Type, as curl --data-binary does, gets the 400 form-body refusal instead (see CI gate usage).

By default, admin endpoints share the main data listener — the same socket that forwards traffic to Docker. This is convenient for single-socket deployments but means any container with socket access can reach the admin API alongside the Docker API.

That default only holds while there is one listener to be default about. With two or more effective listeners and no dedicated admin.listen, admin.mount_on is required and must name the one listener that carries /admin/*; startup fails without it, and the admin endpoints are unreachable from every other listener.

A dedicated admin listener separates the two planes. Admin traffic never traverses the Docker-API filter or the rate limiter, and Docker-API callers never see the admin surface at all. One gate carries over: a TCP admin listener still enforces clients.allowed_cidrs, since CIDRs are the only admission control the admin surface has when TLS isn't configured. Unix-socket admin listeners carry no meaningful client IP, so the guard is TCP-only.

admin:
  enabled: true
  listen:
    socket: /var/run/sockguard-admin/admin.sock  # tighter filesystem perms

Or bind to loopback TCP if your operator tooling connects over the network:

admin:
  enabled: true
  listen:
    address: 127.0.0.1:2376
    tls:
      cert_file: /run/secrets/sockguard/admin-cert.pem
      key_file:  /run/secrets/sockguard/admin-key.pem
      client_ca_file: /run/secrets/sockguard/admin-ca.pem

The dedicated listener supports the same posture as listen.*: unix socket (hardened 0600 mode), loopback or TLS-protected TCP (with admin.listen.tls.cert_file / key_file / client_ca_file plus the same CN / DNS / IP / URI / SHA-256 SPKI client-identity selectors as the main listener), or non-loopback plaintext TCP behind the insecure opt-in flags. SPIFFE IDs are not a listener selector on either listen.tls or admin.listen.tls; spiffe_ids lives on clients.certificate_profiles[*], which maps a client certificate to a named profile rather than gating the listener.

Plaintext TCP beyond loopback takes two flags, admin.listen.insecure_allow_plain_tcp: true and admin.listen.insecure_allow_unauthenticated_clients: true, and one without the other is rejected. A third flag, admin.listen.insecure_allow_wide_open: true, becomes mandatory when clients.allowed_cidrs is empty as well: with no TLS and no CIDR allowlist the admin endpoints would take candidate YAML from any source IP with nothing checking who sent it, so that combination is a startup error rather than a warning.

Recommended posture: configure admin.listen.socket at a path whose parent directory is only writable by the operator UID, e.g. /var/run/sockguard-admin/admin.sock with that directory chmod 700. This keeps admin endpoints entirely off the container-accessible socket while still allowing operator tooling to reach them from the host.

When admin.listen is set, unknown paths on the dedicated listener return 404 rather than falling through to the Docker-API filter. An admin server failure is fatal: the process exits with a wrapped admin server error so a misconfigured dedicated control plane is never silently lost.

The admin.* block (including admin.listen.*) is immutable across hot reload — changing listener binding or TLS material requires a process restart.

POST /admin/validate

Runs the parse → validate → compile pipeline on a YAML body you supply in the request, and returns a structured JSON report. Running policy is never mutated.

One check is deliberately weaker here than in sockguard validate: the endpoint never opens the files a candidate names. sockguard validate and startup load the listen.tls cert, key, and client CA so a missing or malformed file fails fast; POST /admin/validate compiles the same TLS identity constraints but leaves the three paths untouched. Otherwise a caller could point a candidate at any absolute path and read existence, readability, and PEM-validity back out of the errors array, turning the endpoint into a host-filesystem probe. So a 200 from this endpoint does not promise the TLS material on disk is loadable; only a restart or sockguard validate on the host proves that.

Request

POST /admin/validate
Content-Type: application/x-yaml

<candidate sockguard.yaml body>

Body is limited to admin.max_request_bytes (default 512 KiB) via http.MaxBytesReader.

Response codes

StatusMeaning
200 OKYAML is valid; response body contains the parsed policy summary
400 Bad RequestRequest body could not be read
405 Method Not AllowedNon-POST method; Allow: POST header is set
413 Payload Too LargeBody exceeded admin.max_request_bytes
422 Unprocessable EntityYAML failed to parse or failed validation; errors listed

Response body

{
  "ok": true,
  "rules": 6,
  "profiles": 2
}

Every field except ok is omitempty, so a zero or empty value is absent rather than reported as 0 or null. compat_active shows up only when Tecnativa env aliases injected rules, and errors only on a failure.

On a failing candidate (422):

{
  "ok": false,
  "errors": [
    "clients.profiles[0].limits.rate.tokens_per_second must be > 0, got 0",
    "clients.profiles[1].limits.rate.burst must be >= tokens_per_second (5) or 0 (default), got 2"
  ]
}

Error strings are the validator's own output. Most name the config path that failed, often with a list index and the offending value on the end, but not all: a missing-dependency check reads clients.container_labels.label_prefix is required when clients.container_labels.enabled is true, with no index and no value. A parse failure returns a single parse: ... entry instead.

CI gate usage

curl --silent --fail-with-body \
  --data-binary @candidate.yaml \
  http://sockguard:2375/admin/validate | jq .

Exit code is non-zero on HTTP errors, so the pattern works as a pre-promote gate in CI without extra scripting. Use --fail-with-body (curl 7.76 or later) and not plain --fail: --fail throws the response body away on any status at or above 400, which is exactly the 422 case the gate exists for, so the job would fail without ever printing which validation error caused it.

A 400 with reason code request_form_body_refused on this call means the request never reached the admin endpoint. curl labels --data-binary as a form (application/x-www-form-urlencoded), the admin layer is what normally answers before the form-body check, and without it the request falls through to the proxy chain, which refuses form bodies. Check that admin.enabled is true, and that the request goes to the admin listener: the dedicated admin.listen address if you set one, or the listener named by admin.mount_on.

GET /admin/policy/version

Returns a snapshot of the currently active policy: version counter, metadata about which config was loaded, bundle verification results when signed bundles are enabled, and the SHA-256 of the effective config.

Request

GET /admin/policy/version

Response codes

StatusMeaning
200 OKSnapshot available
405 Method Not AllowedNon-GET method; Allow: GET header is set
503 Service UnavailablePolicy snapshot not initialized yet (should not occur after startup)

Response body

{
  "version": 4,
  "loaded_at": "2026-05-13T14:22:01Z",
  "source": "reload",
  "rules": 8,
  "profiles": 3,
  "compat_active": false,
  "config_sha256": "a3f2b7c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
  "bundle_source": "operator-signed",
  "bundle_signer": "keyless:https://token.actions.githubusercontent.com:https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/heads/main",
  "bundle_digest": "b4c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7"
}
FieldTypeNotes
versionintMonotonic counter. Increments at startup and once per successful hot reload. A stable value across two queries means the policy genuinely did not change.
loaded_atRFC3339 stringTimestamp when this version became active
sourcestring"startup" or "reload"
rulesintNumber of compiled filter rules in the active policy
profilesintNumber of named client profiles
compat_activebooltrue when Tecnativa env-var compat rules are in effect
config_sha256hex stringSHA-256 of the effective config's JSON encoding. Best-effort; may be empty if the encoder fails. Two snapshots with the same version but different hashes indicate a config-drift bug.
bundle_sourcestringOmitted when policy_bundle.enabled: false. The basename of policy_bundle.signature_path, with the directory stripped so the response does not leak the host's filesystem layout to Docker API callers on the main listener.
bundle_signerstringOmitted when bundles disabled. keyed:<spki-fingerprint> for keyed verification or keyless:<issuer>:<san> for keyless.
bundle_digesthex stringOmitted when bundles disabled. SHA-256 of the verified YAML bytes.

The sockguard_policy_version Prometheus gauge mirrors the same counter, so GET /admin/policy/version and the gauge always agree.

Confirming a reload took effect

# Before promoting the new config:
curl -s http://sockguard:2375/admin/policy/version | jq .version
# → 3

# Send SIGHUP or wait for fsnotify:
kill -HUP $(pidof sockguard)

# After:
curl -s http://sockguard:2375/admin/policy/version | jq .version
# → 4

A stable version after a SIGHUP means the reload was rejected — check sockguard_config_reload_total{result!="ok"} and the structured logs for the rejection reason.