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+ listenersBoth 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).
Dedicated admin listener (recommended)
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 permsOr 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.pemThe 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.socketat a path whose parent directory is only writable by the operator UID, e.g./var/run/sockguard-admin/admin.sockwith that directorychmod 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
| Status | Meaning |
|---|---|
200 OK | YAML is valid; response body contains the parsed policy summary |
400 Bad Request | Request body could not be read |
405 Method Not Allowed | Non-POST method; Allow: POST header is set |
413 Payload Too Large | Body exceeded admin.max_request_bytes |
422 Unprocessable Entity | YAML 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/versionResponse codes
| Status | Meaning |
|---|---|
200 OK | Snapshot available |
405 Method Not Allowed | Non-GET method; Allow: GET header is set |
503 Service Unavailable | Policy 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"
}| Field | Type | Notes |
|---|---|---|
version | int | Monotonic counter. Increments at startup and once per successful hot reload. A stable value across two queries means the policy genuinely did not change. |
loaded_at | RFC3339 string | Timestamp when this version became active |
source | string | "startup" or "reload" |
rules | int | Number of compiled filter rules in the active policy |
profiles | int | Number of named client profiles |
compat_active | bool | true when Tecnativa env-var compat rules are in effect |
config_sha256 | hex string | SHA-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_source | string | Omitted 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_signer | string | Omitted when bundles disabled. keyed:<spki-fingerprint> for keyed verification or keyless:<issuer>:<san> for keyless. |
bundle_digest | hex string | Omitted 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
# → 4A 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.
Observability
Prometheus metrics, the active upstream watchdog, the Docker API readiness probe, and W3C trace correlation. Wire Sockguard into Prometheus, Grafana, and your existing tracing pipeline without an OTLP exporter.
Migration
Upgrade Sockguard through v2.1, including the signed-policy trust split and the v2.0-to-v2.1 TLS-verification deprecation, or migrate from another Docker socket proxy.