devmon-agent (0.6.0)

Download OpenAPI specification:

URL: https://github.com/scnplt/devmon-agent License: AGPL-3.0-only

A narrow, mTLS-authenticated Docker control API.

One agent runs per Docker host. It is the sole holder of the Docker socket and never proxies arbitrary Engine calls: the operations below are the complete reachable surface, and read responses are allowlisted projections rather than Engine payloads — a container's environment and a volume's driver options never leave the host.

Versioning. The path prefix /v1 is the contract version, and GET /v1/status reports it as api_version. A client negotiates against that field, not against agent_version, which moves with releases that do not change this document.

Authentication is mutual TLS. The agent is its own certificate authority; pairing is the act of issuing a device a certificate. Every route except GET /v1/status and POST /v1/pair requires a client certificate issued by that CA, and the calling device is resolved from the certificate — never from a header, a body field, or a path parameter. A client pins the agent's CA; no public root store knows it.

What a client may do is bounded twice. By the host's policy mode, fixed at agent startup and advertised in /v1/status, which a client can never widen; and by the agent's permanent exclusion of its own container from every mutating route, in every mode.

Retention, policy, and rate limits are all startup configuration on the host. There is deliberately no endpoint that changes any of them.

status

The one unauthenticated route. It may inform, never issue.

Agent version, policy mode, server time, and CA fingerprint

The only payload served without a client certificate, and a strict allowlist of five fields. It carries no host, container, or credential data, and it can never issue or renew anything.

Its purpose is diagnostic: an expired device certificate and a regenerated server identity produce the same handshake failure but demand opposite user guidance. Comparing ca_fingerprint against the value recorded at install separates them, and server_time lets a client tell a real expiry from its own clock being wrong.

Responses

Response samples

Content type
application/json
{
  • "api_version": "v1",
  • "agent_version": "0.6.0",
  • "policy_mode": "default",
  • "server_time": "2026-08-11T09:12:44Z",
  • "ca_fingerprint": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
}

pairing

Exchanging a pairing code or an existing certificate for a client certificate.

Exchange a pairing code and a CSR for a client certificate

Reachable without a client certificate because a device that has none is exactly what this route exists to serve: the operator-minted pairing code is the credential for this one request. Codes are short-lived and single-use, and are minted on the host with devmon-agent device pair-code --name <device>.

The device generates its own keypair and sends only the CSR; the private key never leaves it. The response carries the issued certificate and the CA certificate to pin.

Every reason this call can fail — an unknown, expired, or already-spent code, or a malformed CSR — answers an identical 401. A caller must not be able to tell which, or the route becomes an oracle for probing codes.

Request Body schema: application/json
required
pairing_code
required
string

A short-lived, single-use code minted on the host with devmon-agent device pair-code --name <device>.

csr_pem
required
string

A PKCS#10 certificate signing request, PEM-encoded as a CERTIFICATE REQUEST block. The device generates the keypair; the private key never leaves it. The subject is not trusted — the agent binds the certificate to the device ID it allocates.

Responses

Request samples

Content type
application/json
{
  • "pairing_code": "string",
  • "csr_pem": "string"
}

Response samples

Content type
application/json
{
  • "device_id": "string",
  • "certificate_pem": "string",
  • "ca_certificate_pem": "string",
  • "not_after": "2019-08-24T14:15:22Z"
}

Exchange a CSR for a fresh certificate for the calling device

Renewal is proactive and silent: a client renews well before expiry, while its current certificate still authenticates it. Once a certificate has expired there is no authenticated channel left to authorise its replacement, so a client that waits has to be re-paired on the host.

The device identity comes from the client certificate alone, so this route can only ever renew the caller's own. The previous certificate is marked superseded but is neither deleted nor shortened — it keeps working until its own not_after, so a lost response cannot strand a device.

Authorizations:
deviceCertificate
Request Body schema: application/json
required
csr_pem
required
string

A PKCS#10 CSR, PEM-encoded. A fresh keypair is recommended but not required.

Responses

Request samples

Content type
application/json
{
  • "csr_pem": "string"
}

Response samples

Content type
application/json
{
  • "certificate_pem": "string",
  • "not_after": "2019-08-24T14:15:22Z"
}

Revoke the calling device's own access

Permitted under every policy mode, including read-only: giving up your own access is not a privileged act. It takes effect on the next request.

A device can only ever act on itself. There is no route for revoking another device, because with no roles there is no administrator client — any such capability would belong equally to a stolen phone. Revoking a lost device is a host-side operation: devmon-agent device revoke <id>.

Authorizations:
deviceCertificate

Responses

Response samples

Content type
application/json
{
  • "error": "client certificate required"
}

containers

Container reads.

List containers

Authorizations:
deviceCertificate
query Parameters
all
boolean
Default: false

Include stopped containers. Anything that does not parse as a boolean is treated as false rather than rejected — a typo must not fail a diagnostic request mid-incident.

Responses

Response samples

Content type
application/json
{
  • "truncated": true,
  • "items": [
    ]
}

Inspect one container

The projection deliberately omits the container's environment. Redacting a value would still disclose that a secret exists and what it is called; omission is the only version of this that cannot leak.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "image": "string",
  • "image_id": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "state": "running",
  • "running": true,
  • "paused": true,
  • "restarting": true,
  • "exit_code": 0,
  • "started_at": "2019-08-24T14:15:22Z",
  • "finished_at": "2019-08-24T14:15:22Z",
  • "health": "string",
  • "restart_count": 0,
  • "restart_policy": "no",
  • "platform": "linux",
  • "labels": {
    },
  • "command": "string",
  • "args": [
    ],
  • "entrypoint": [
    ],
  • "working_dir": "string",
  • "user": "string",
  • "mounts": [
    ],
  • "networks": [
    ],
  • "ports": [
    ],
  • "protected": true
}

logs

Historical log retrieval and the live stream.

Recent log lines as JSON

Permitted in every policy mode. tail defaults to 200 here.

stdout and stderr are interleaved in the order the container wrote them: the agent demultiplexes Docker's stream framing itself rather than splitting the two, because that ordering is most of what a log is worth during an incident.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

query Parameters
tail
integer [ 1 .. 2000 ]

Number of lines to return. Bounded to 1…2000; a value that is absent, unparsable, or out of range falls back to the route's default (200 historical, 100 for the stream) rather than failing the request.

since
string <date-time>
Example: since=2026-08-08T10:02:14.882Z

Return only lines at or after this timestamp, RFC 3339 with nanosecond precision. On the stream route this is the resume cursor: pass the id of the last frame received.

Unlike tail, an unparsable value is a 400 rather than a silently ignored preference — it reaches the Engine's own request URL, which makes it a boundary input.

Responses

Response samples

Content type
application/json
{
  • "truncated": true,
  • "items": [
    ]
}

Live log stream (Server-Sent Events)

Permitted in every policy mode. tail defaults to 100 here, seeding the backlog before the stream follows live output.

Each frame carries one line, with the timestamp lifted out of Docker's wire format into its own field so a client never parses that format to find its resume cursor:

id: 2026-08-08T10:02:14.882Z
event: log
data: {"ts":"2026-08-08T10:02:14.882Z","stream":"stderr","line":"panic: nil map write"}

Resuming is at-least-once. After a connection loss, reconnect with ?since=<the last id seen>. Docker's since filter is inclusive at its granularity boundary, so a resume can repeat the last line or two; dedupe on (ts, line). A repeated line is cosmetic, a dropped one is a diagnostic failure, and the trade goes that way round deliberately.

A silent stream is still live. A keepalive comment frame (: keepalive) is written every 20 seconds, which is also how the agent learns a client vanished without closing.

Eight concurrent streams per agent, after which this route answers 503. Each stream holds a goroutine, an Engine connection, and a socket for its whole life.

Response headers are withheld until there is a first frame to send, which is what keeps 404 and 502 available as ordinary JSON responses. Once the stream has started, a later failure can only arrive as a terminal event: error frame on a response that already said 200.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

query Parameters
tail
integer [ 1 .. 2000 ]

Number of lines to return. Bounded to 1…2000; a value that is absent, unparsable, or out of range falls back to the route's default (200 historical, 100 for the stream) rather than failing the request.

since
string <date-time>
Example: since=2026-08-08T10:02:14.882Z

Return only lines at or after this timestamp, RFC 3339 with nanosecond precision. On the stream route this is the resume cursor: pass the id of the last frame received.

Unlike tail, an unparsable value is a 400 rather than a silently ignored preference — it reaches the Engine's own request URL, which makes it a boundary input.

Responses

Response samples

Content type
text/event-stream
id: 2026-08-08T10:02:14.882Z
event: log
data: {"ts":"2026-08-08T10:02:14.882Z","stream":"stderr","line":"panic: nil map write"}

: keepalive

id: 2026-08-08T10:02:44.001Z
event: error
data: {"error":"docker engine unavailable"}

events

The live container health and lifecycle event stream.

Live container health and lifecycle events (Server-Sent Events)

Permitted in every policy mode: the stream discloses a strict subset of what GET /v1/containers already returns.

The snapshot replaces replay. The first frame is always one event: snapshot carrying the current state of every container, taken only after the Engine-side event subscription is live, so no event can fall between the two. There is no since parameter and no Last-Event-ID resume: a client reconciles from the snapshot on every connect, which is one mechanism that is always true rather than a replay window that is bounded and stale at its edges.

After the snapshot, one event: health frame per forwarded Engine event. Exactly six container events are forwarded — health_status: healthy, health_status: unhealthy, die, start, stop, and oom. Everything else, including every event attribute the Engine attaches (attributes are the container's label set), is dropped agent-side.

One stream per paired device, newest wins. A second connection from the same device closes the older one with a terminal event: error / event stream superseded frame — the one terminal error a client must not retry, since reconnecting would fight the newer stream. This limit is independent of the log-stream budget: an event stream consumes no log-stream slot, because every event stream shares one Engine subscription per agent process.

A gap becomes a disconnect. If the Engine feed dies, every client receives event: error / docker engine unavailable and the stream closes; a client that falls too far behind receives event stream fell behind. Both are retryable with backoff, and a reconnect serves a fresh snapshot.

A : heartbeat comment frame is written every 25 seconds so NAT and proxies keep the connection open, and so a client can detect a dead stream.

id is the join key across routes. name here carries the Engine name with its leading / stripped (the Engine's list and event APIs disagree on the form), which differs from GET /v1/containers's names.

Snapshot health requires Engine 29+ / API v1.52: on an older Engine every container snapshots as none while the events themselves are unaffected.

Response headers are withheld until the snapshot is ready to send, which keeps 502 available as an ordinary JSON response. Once the stream has started, a later failure can only arrive as a terminal event: error frame on a response that already said 200.

Authorizations:
deviceCertificate

Responses

Response samples

Content type
text/event-stream
event: snapshot
data: [{"id":"a1b2c3","name":"api","state":"running","health":"healthy"},{"id":"d4e5f6","name":"cron","state":"exited","health":"none"}]

: heartbeat

event: health
data: {"id":"a1b2c3","name":"api","event":"health_status","health":"unhealthy","time":"2026-08-25T09:14:02Z"}

event: health
data: {"id":"d4e5f6","name":"cron","event":"die","time":"2026-08-25T09:31:47Z"}

event: error
data: {"error":"event stream superseded"}

lifecycle

Mutating container operations, bounded by the host's policy mode.

Delete a stopped container

Requires policy mode full.

Delete never force-stops. A running container is a 409, so removing one is stop-then-delete: two deliberate operations leaving two audit rows, rather than one tap that destroys a running service.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

Responses

Response samples

Content type
application/json
{
  • "error": "invalid object reference"
}

Start a stopped container

Requires policy mode default or full.

Starting a container that is already running is a 204, not an error: the goal is already met, and reporting a failure would invite a retry that cannot help.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

Responses

Response samples

Content type
application/json
{
  • "error": "invalid object reference"
}

Restart a container

Requires policy mode default or full.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

Responses

Response samples

Content type
application/json
{
  • "error": "invalid object reference"
}

Stop a running container

Requires policy mode default or full.

Stopping a container that is already stopped is a 204, on the same reasoning as start.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

Responses

Response samples

Content type
application/json
{
  • "error": "invalid object reference"
}

SIGKILL a running container

Requires policy mode full.

Always SIGKILL — there is no ?signal= parameter, because the kill button means "stop this now". A graceful stop is POST .../stop.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Container ID (full or short) or name, matching the reference pattern every object route enforces — see the pattern below.

A reference is interpolated into the Engine's own request URL, so it is validated at the boundary rather than trusted to two layers of framework path handling. Anything outside the pattern is a 400 and never reaches the Engine.

Responses

Response samples

Content type
application/json
{
  • "error": "invalid object reference"
}

images

Image reads.

List images

Authorizations:
deviceCertificate

Responses

Response samples

Content type
application/json
{
  • "truncated": true,
  • "items": [
    ]
}

Inspect one image

The image's baked-in environment is not mapped, for the same reason a container's is not.

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Image ID as bare hex, or an untagged repository name.

The reference pattern excludes :, so a digest (sha256:abc…) and a tagged reference (nginx:1.27) are both rejected with 400 rather than forwarded. Strip the sha256: prefix from an ID taken out of a list response, where it may appear.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "repo_tags": [
    ],
  • "repo_digests": [
    ],
  • "created_at": "2019-08-24T14:15:22Z",
  • "size": 0,
  • "architecture": "amd64",
  • "os": "linux",
  • "author": "string",
  • "comment": "string"
}

networks

Network reads.

List networks

Authorizations:
deviceCertificate

Responses

Response samples

Content type
application/json
{
  • "truncated": true,
  • "items": [
    ]
}

Inspect one network, including its attached containers

Authorizations:
deviceCertificate
path Parameters
id
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Network ID or name, matching the same reference pattern every object route enforces.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "driver": "bridge",
  • "scope": "local",
  • "created_at": "2019-08-24T14:15:22Z",
  • "internal": true,
  • "enable_ipv6": true,
  • "labels": {
    },
  • "containers": [
    ]
}

volumes

Volume reads.

List volumes

Authorizations:
deviceCertificate

Responses

Response samples

Content type
application/json
{
  • "truncated": true,
  • "items": [
    ]
}

Inspect one volume

There is no separate detail shape for a volume: inspect returns the same object a list entry carries.

Driver options are not mapped. For tmpfs and CIFS/NFS volumes they routinely contain credentials.

Authorizations:
deviceCertificate
path Parameters
name
required
string^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$

Volume name. Volumes are named rather than IDed, which is why this parameter is name and not id. Same reference pattern as every other object route.

Responses

Response samples

Content type
application/json
{
  • "name": "string",
  • "driver": "local",
  • "mountpoint": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "scope": "local",
  • "labels": {
    },
  • "size_bytes": 0
}