ctrl-exec API
HTTP REST API reference, OpenAPI spec, and live spec generator
This document covers the HTTP REST API exposed by ctrl-exec-api, the
OpenAPI specification, and the live spec generator that augments the spec
with discovered host and script data.
For installation and configuration of the API server, see INSTALL.md. For the agent-side wire format (the mTLS protocol between ctrl-exec and agents), see DEVELOPER.md.
Overview
ctrl-exec-api exposes the same run, ping, and discovery operations as the
ctrl-exec CLI, as HTTP endpoints with JSON request and response bodies. The
auth hook and lock checking apply identically to CLI and API requests.
Endpoints: GET /, GET /health, POST /ping, POST /run,
GET /discovery, POST /discovery, GET /status/{reqid},
GET /openapi.json, GET /openapi-live.json.
The server listens on api_port (default 7445). TLS is enabled if api_cert
and api_key are set in ctrl-exec.conf; plain HTTP is used otherwise.
The server uses a fork-per-request model: the parent accepts connections and
forks a child per request. The child handles the request and exits. The parent
reaps children with a SIGCHLD handler calling waitpid(-1, WNOHANG).
Endpoints
GET /
Returns a JSON index of all endpoints. Use this to discover available endpoints and spec URLs programmatically.
{
"name": "ctrl-exec-api",
"version": "0.2.8",
"spec": "/openapi.json",
"live_spec": "/openapi-live.json",
"endpoints": [
{ "method": "GET", "path": "/health" },
{ "method": "POST", "path": "/ping" },
{ "method": "POST", "path": "/run" },
{ "method": "GET", "path": "/discovery" },
{ "method": "POST", "path": "/discovery" },
{ "method": "GET", "path": "/status/{reqid}" },
{ "method": "GET", "path": "/openapi.json" },
{ "method": "GET", "path": "/openapi-live.json" }
]
}
GET /health
Returns the API server version. Use for liveness checks.
{ "ok": true, "version": "0.2.8" }
POST /ping
Runs the auth hook, then pings all specified hosts in parallel via
Engine::ping_all. Returns per-host connectivity, cert expiry, and version.
Individual host failures are reported inline; they do not produce an HTTP
error response.
Request body:
{ "hosts": ["web-01", "web-02"], "username": "alice", "token": "mytoken" }
hosts-
Required. Array of agent hostnames. Each entry may be
hostnameorhostname:port; port defaults to 7443. username,token- Optional. Passed to the auth hook. See Auth hook below.
Response:
{
"ok": true,
"results": [
{ "host": "web-01", "status": "ok", "rtt": "12ms", "expiry": "Jan 15 12:00:00 2026 GMT", "version": "0.2.8" },
{ "host": "web-02", "status": "error", "rtt": "60001ms", "error": "read timeout after 60s" }
]
}
POST /run
Runs the auth hook, checks locks via Lock::check_available, then dispatches
a script to all specified hosts in parallel via Engine::dispatch_all.
Individual host failures are reported inline via the exit and error fields.
Request body:
{
"hosts": ["db-01", "db-02"],
"script": "pg-backup",
"args": ["--database", "myapp"],
"username": "alice",
"token": "mytoken"
}
hosts- Required. Non-empty array of agent hostnames.
script-
Required. Allowlisted script name. Alphanumeric and hyphens only. Must
match an entry in the agent's
scripts.conf. args- Optional. Array of positional arguments passed to the script.
username,token- Optional. Passed to auth hook and forwarded to the agent as request context.
Response (success):
{
"ok": true,
"reqid": "a1b2c3d4",
"results": [
{ "host": "db-01", "exit": 0, "stdout": "Backup complete\n", "stderr": "", "rtt": "4210ms", "reqid": "a1b2c3d4" },
{ "host": "db-02", "exit": -1, "error": "read timeout after 60s", "rtt": "60001ms", "reqid": "a1b2c3d5" }
]
}
reqid-
Request ID at the top level of the response. Matches
REQIDin syslog on both ctrl-exec and agent. Use to pollGET /status/{reqid}or to correlate log entries across both sides. exit- Script exit code. 0 = success. Positive = script failure. -1 = ctrl-exec-side failure (connection error, timeout). 126 = killed by signal or exec failed.
Response (lock conflict):
{ "ok": false, "error": "locked", "code": 4, "conflicts": ["db-01"] }
GET /status/{reqid}
Returns the stored result for a completed run. Results are persisted to
/var/lib/ctrl-exec/runs/<reqid>.json for 24 hours after the run
completes, then purged.
This endpoint supports an async polling pattern: submit a run with
POST /run, record the top-level reqid, then poll
GET /status/{reqid} at a suitable interval. The calling programme
controls the polling logic; there is no push or callback mechanism.
Response (found):
{
"ok": true,
"reqid": "a1b2c3d4",
"script": "pg-backup",
"hosts": ["db-01", "db-02"],
"completed": 1737123456,
"results": [
{ "host": "db-01", "exit": 0, "stdout": "Backup complete\n", "stderr": "", "rtt": "4210ms", "reqid": "a1b2c3d4" }
]
}
completed- Unix timestamp of when the run completed and the result was stored.
Response (not found): 404 with { ok: false, error: "not found", detail: "no result for reqid <id>" }.
A 404 means either the reqid never existed, the result has been purged after 24 hours, or the run was submitted before this version of the API was deployed (earlier versions did not persist results).
GET /discovery or POST /discovery
Returns all registered agents and their allowlisted scripts. Auth uses the ping privilege level.
The GET form queries all registered agents. The POST form accepts an optional body to filter to a specific set of hosts.
Optional request body (POST only):
{ "hosts": ["web-01", "db-01"], "username": "alice", "token": "mytoken" }
If hosts is omitted or the body is absent, all agents in the registry are
queried.
Response:
{
"ok": true,
"hosts": {
"web-01": {
"host": "web-01", "status": "ok", "version": "0.2.8",
"tags": { "env": "production", "role": "web" },
"scripts": [
{ "name": "deploy", "path": "/opt/ctrl-exec-scripts/deploy.sh", "executable": true }
]
}
}
}
tags is an object of key/value strings defined in the [tags] section of
the agent's agent.conf. An agent with no tags configured returns "tags": {}.
Results are keyed by hostname for direct lookup.
HTTP status codes
200 Success
400 Bad request (missing body, invalid JSON, missing required field)
403 Auth denied
404 Unknown route or unknown/expired reqid (status endpoint)
409 Lock conflict
500 Server error
Auth error codes in the code field:
1 denied
2 bad credentials
3 insufficient privilege
4 lock conflict (409 only)
Auth hook
If auth_hook is set in ctrl-exec.conf, it is called before every
request including /run, /ping, /discovery, and all informational
endpoints. The hook receives the full request context as JSON on stdin,
including action, script, hosts, username, token, and
source_ip. Exit codes follow the same convention as the CLI: 0 = authorised,
1 = denied, 2 = bad credentials, 3 = insufficient privilege.
If no hook is configured, behaviour is governed by api_auth_default in
ctrl-exec.conf. The default is deny - all requests return 403 until a
hook is configured. Set api_auth_default = allow only on isolated networks
where no credential checking is required.
Always use ENVEXEC_ARGS_JSON in hook scripts to inspect script arguments.
ENVEXEC_ARGS (space-joined) is deprecated and unreliable for arguments
containing spaces or newlines.
OpenAPI spec
The static OpenAPI 3.1 spec is installed at
/usr/local/lib/ctrl-exec/ctrl-exec/openapi.json and served verbatim from
GET /openapi.json. The version field is stamped with the release version at
install time.
The spec describes all request and response schemas in full. It is suitable for import into any OpenAPI-compatible tooling.
Live spec generator
GET /openapi-live.json generates and serves a dynamic OpenAPI spec augmented
with live discovery data. It is intended for use with UI tools such as RapiDoc,
which load a spec URL and render an interactive interface.
What it does
On each request:
- Loads and parses the base spec from
openapi.jsonon disk. - Pulls all registered hostnames from the local registry (no network call).
- Runs
capabilities_allagainst those hosts in parallel. Hosts that do not respond are silently omitted from enumeration. - Injects an
enumarray into thehostsfield across thePingRequest,RunRequest, andDiscoveryRequestschemas. - Injects an
enumarray into thescriptfield inRunRequest- all script names seen across reachable agents, deduplicated and sorted. - Stamps
info.versionwith an epoch suffix in the form0.2.8+1737123456. Any existing epoch suffix is stripped first so repeated requests do not accumulate suffixes. - Encodes and serves the result in memory. No file is written to disk.
Regeneration
The live spec is regenerated on each request. There is no caching, no file watcher, and no scheduled job. A browser refresh picks up changes in host or script availability.
Using with RapiDoc
Point RapiDoc's spec-url at /openapi-live.json. Because the version stamp
changes on each generation, RapiDoc treats each response as a fresh spec.
The RapiDoc page itself is static; all dynamism is in the spec endpoint.
<rapi-doc spec-url="/openapi-live.json"></rapi-doc>
Scope
The live spec generator is a testing and validation aid for the current phase. It will be superseded when a middleware layer wraps scripts, adds auth and workflow, and exposes its own API. The generator should not grow beyond its current scope.
Exec::API module
Implemented in lib/ctrl-exec/API.pm. Public interface:
run(%opts)-
Required:
config. Starts the server and blocks until SIGTERM or SIGINT. All other behaviour is internal.
SSL_no_shutdown => 1 is used on connection close in both parent and child,
for the same reason as in the pairing server - see the note in
Exec::Pairing in DEVELOPER.md.