Configuration, HTTP API, CLI, exit codes, logging, build and deployment.
DPSAPI - Docker PS API
dpsapi is a small Go HTTP service exposing one authenticated REST endpoint, GET /api/v1/containers, which returns the Docker containers that are currently running (the equivalent of docker ps), read directly from the Docker Engine API on /var/run/docker.sock.
The same binary manages the API tokens and reads the audit log: with no arguments it runs the HTTP server in the foreground; with token ... arguments it performs a token operation against the SQLite database and exits; with audit ... arguments it prints audit rows and exits; help prints the usage text.
Build and test
The makefile reads its build variables from .vars (create it with ./initialize.sh, which copies vars.example).
| Command | Effect |
|---|---|
make build |
Static local binary ./dpsapi (CGO_ENABLED=0, version and commit embedded) |
make test |
go test ./... |
make test-race |
go test -race ./... (needs cgo / a C compiler) |
make docker |
Builds the image ${CONTAINER_ORG}/${CONTAINER_IMAGE}:${APP_VERSION} |
make test-e2e |
make build, then the Playwright suite in e2e/ against the local binary (--project=host) |
make docker-e2e |
make docker, then the Playwright suite in e2e/ against the image (--project=image, image reference exported as DPSAPI_IMAGE) |
make dockerpush |
Pushes the image |
make dockerrun |
Runs the image with the Docker socket and ./data mounted |
make run |
go run . inside the Go builder image with the Docker socket and ./data mounted |
make clean |
Removes ./dpsapi and this project's images (${CONTAINER_ORG}/${CONTAINER_IMAGE}:* and dangling images labelled dpsapi.project=${REPO_ORG}/${REPO_NAME}) |
make version (default) |
Interactive: asks for a version, tags it in git, runs make docker, optionally pushes |
APP_VERSION defaults to latest and can be set from the environment (APP_VERSION=1.0.0 make docker). COMMIT_ID is the short HEAD hash, or undefined when the repository has no commits.
The image is built in two stages: golang:1.27.1-alpine compiles a static binary, and the final scratch stage contains only /app (no shell, no Docker CLI, no certificate bundle).
Configuration
Configuration comes only from environment variables. It is validated before anything else happens, in both server and CLI mode.
| Variable | Required | Default | Accepted values |
|---|---|---|---|
DB_PATH |
yes | none | Path of the SQLite database file. Relative paths are resolved against the working directory. The parent directory must exist and be writable (SQLite also creates -wal/-shm files next to the database). |
HTTP_ADDR |
no | 0.0.0.0 |
A literal IPv4 or IPv6 address (no hostnames, brackets, ports or zones). An IPv4 address listens on IPv4 only; an IPv6 address such as :: listens dual-stack where the OS allows it. |
HTTP_PORT |
no | 80 |
Decimal integer 1–65535, ASCII digits only (no sign, no whitespace; leading zeros are accepted). |
LOG_LEVEL |
no | INFO |
ERROR, WARN, INFO, DEBUG, TRACE, ASCII case-insensitive, exact match. |
A variable that is set but empty is an error for all four variables. Variables are checked in the order DB_PATH, HTTP_ADDR, HTTP_PORT, LOG_LEVEL; the first problem is reported. Configuration errors take precedence over command-line usage errors.
Configuration error messages (exit code 2):
dpsapi: configuration error: DB_PATH is required
dpsapi: configuration error: <VAR> must not be empty
dpsapi: configuration error: invalid HTTP_ADDR "<value>": must be an IPv4 or IPv6 address
dpsapi: configuration error: invalid HTTP_PORT "<value>": must be an integer between 1 and 65535
dpsapi: configuration error: invalid LOG_LEVEL "<value>": must be one of ERROR, WARN, INFO, DEBUG, TRACE
<value> is Go-quoted, so control characters are escaped.
Database schema
The database is created on first use and the schema is applied idempotently (CREATE TABLE IF NOT EXISTS) every time the server or a CLI command opens it. Foreign-key enforcement, WAL journaling and a 5 s busy timeout are enabled on every connection, so the server and CLI commands can use the same file concurrently.
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY NOT NULL,
description TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL CHECK (active IN (0, 1)),
created_at TEXT NOT NULL,
deleted_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS operations (
token TEXT NOT NULL,
origin TEXT NOT NULL,
operation TEXT NOT NULL,
time TEXT NOT NULL,
FOREIGN KEY (token) REFERENCES tokens(token)
);
- All timestamps are UTC RFC3339 with nanoseconds as produced by Go's
time.RFC3339Nano(trailing zeros of the fraction are dropped, e.g.2026-10-05T20:03:15.9065Z,2026-01-01T00:00:00Z). deleted_atisNULLwhile a token is active.- Deleting a token sets
active = 0anddeleted_atin one transaction; the row is kept so audit rows keep referencing it. operationsgets exactly one row per authenticatedGET /api/v1/containersrequest, withoperation = 'GET containers'andorigin= the IP address of the TCP peer. CLI commands never write tooperations;auditonly reads it.
Server
DB_PATH=/data/dpsapi.db ./dpsapi
The server listens on ${HTTP_ADDR}:${HTTP_PORT} in the foreground. The Docker client is created lazily: a missing or inaccessible socket does not prevent startup, it makes each authenticated request fail with 500.
On SIGINT or SIGTERM the server stops accepting connections, waits up to 15 s for in-flight requests, closes the database and exits 0. If in-flight requests do not finish in time the exit code is 1 (dpsapi: http server shutdown: context deadline exceeded). A listen failure exits 1, e.g.:
dpsapi: listen tcp4 127.0.0.1:8080: bind: address already in use
HTTP server timeouts: read header 10 s, read 30 s, write 30 s, idle 120 s. Each Docker Engine call is bounded by 10 s.
CLI
dpsapi help
dpsapi token list [all]
dpsapi token new [description]
dpsapi token delete <token>
dpsapi audit [all | n | n<unit>]
Table output
token list and audit print a header line followed by one line per row, as a column-aligned table padded with spaces (Go's text/tabwriter with a padding of 2 and no tabs in the output):
- Every column except the last is as wide as its widest cell, header included, plus 2. Cell lengths are counted in Unicode code points (runes): not bytes, and not terminal display width (a CJK character or an emoji counts as 1).
- Each cell of those columns is followed by spaces up to the column width. The cell of the last column is written as-is, never padded, so no line has trailing whitespace. Every line ends with
\n. - Widths are computed per invocation over the printed lines only, so they change with the selected rows.
- With no rows only the header line is printed, each header cell padded by its own width plus 2 (
TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT,TIME TOKEN ORIGIN OPERATION).
help
Prints the usage text (shown below) to stdout and exits 0; nothing is written to stderr except DEBUG/TRACE log records when LOG_LEVEL asks for them. The database is not opened or created. The configuration is still validated first, so a configuration error wins over help. help takes no arguments (help <anything> is a usage error) and is case-sensitive: Help, -h and --help are unknown commands.
token new [description]
Generates a token (32 bytes from crypto/rand, base64url without padding: 43 characters from [A-Za-z0-9_-]), stores it as active, and prints exactly the token followed by a newline on stdout. The description is optional (default empty string) and is stored verbatim, byte for byte. Quote descriptions containing spaces; more than one argument is a usage error.
token list [all]
token list prints the active tokens only; token list all prints every token, active and inactive. Both print a padded table (see Table output) with one line per token, ordered by creation time then token. token list all:
TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT
whEsbopp2tB2xXN7IC3LBnHhVge2F_-Gf910hIDL2pQ "smoke test" false 2026-10-05T20:03:15.894338161Z 2026-10-05T20:03:15.94012Z
S_muyryYKV3fegqvOSlEbHJ8ZvHz_s5tPov3dkY49Bo "" true 2026-10-05T20:05:01.042950671Z -
token list on the same database:
TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT
S_muyryYKV3fegqvOSlEbHJ8ZvHz_s5tPov3dkY49Bo "" true 2026-10-05T20:05:01.042950671Z -
allis the only accepted argument and is case-sensitive: any other argument (ALL,--all, an empty string, ...) is the usage errorunknown token list argument; two or more arguments are the usage errortoken list takes at most one argument.TOKENis the full 43-character token, so with at least one row the column is always 45 characters wide.DESCRIPTIONis rendered with Go'sstrconv.Quote, so every row is exactly one line whatever the description contains ("line1\nline2","tab\there","\xff"). Printable non-ASCII characters are kept as-is and count as one character each for the padding.ACTIVEistrueorfalse(alwaystruewithoutall).DELETED_ATis-while the token is active.- With no (matching) tokens only the header line is printed.
token delete <token>
Deactivates the token. Prints nothing on success. The token argument is matched byte-exact (case-sensitive, no trimming).
audit [all | n | n<unit>]
Prints audit rows (the operations table) as a padded table (see Table output) with the columns TIME, TOKEN, ORIGIN and OPERATION, one line per row, oldest first and newest last (like tail):
TIME TOKEN ORIGIN OPERATION
2026-10-05T22:36:01.897010677Z hr9709HRvMYCsbHSwvWqqLJGUFLyjJtL8VTiQ91k1uU 127.0.0.1 GET containers
2026-10-05T22:36:01.910494251Z hMsqw73OrD3LiWZAm5B0yAoASaph5Kn3gqAQEhUfX0s 127.0.0.1 GET containers
| Invocation | Rows printed |
|---|---|
audit |
The latest 10 rows |
audit <n> |
The latest n rows (all rows if fewer exist) |
audit <n><unit> |
Every row whose time is at or after now − n × unit (the boundary is inclusive). Units: s seconds, m minutes, h hours, d days (24 h), w weeks (7 d). |
audit all |
Every row |
nis ASCII decimal digits only and at least1; leading zeros are accepted (010=10,01h=1h). Unit letters are lowercase only.- The largest accepted values are
9223372036854775807rows and a window of about 292 years (9223372036s,153722867m,2562047h,106751d,15250w). allis the only accepted word, and only in lowercase:ALL,All,--all,alls,all1,1allorallsurrounded by whitespace are not accepted.- Anything else is a usage error (
invalid audit argument, oraudit takes at most one argumentfor two or more arguments, includingaudit all <anything>):0(also0h), signs, whitespace, decimals or other number syntaxes, an unknown or uppercase unit, a unit without a number, any word other than lowercaseall, an empty argument, or a value above those limits. - "Latest" and the printed order use the stored timestamp as a point in time (not its text), with rows stored at the same instant kept in insertion order. The window has no upper bound: a row with a time later than now (clock skew) is included.
TIMEandTOKENare printed exactly as stored.TOKENis the full token value, as intoken list, so the token can be matched to its row there. Treat the output as sensitive.ORIGINis the TCP peer IP address andOPERATIONis alwaysGET containers(it is the last column, so its space is never followed by padding).TIMEvalues have variable length (the fraction is stored without trailing zeros), so theTIMEcolumn width depends on the printed rows.- With no matching rows only the header line is printed (exit
0). - The command only reads: it never writes to
tokensoroperations. Like every command, it creates the database and the empty schema ifDB_PATHdoes not exist yet. - A row whose stored time cannot be parsed makes the command fail with exit
1(dpsapi: read operation row: parse stored timestamp: ...) rather than print the rows in the wrong order.
Exit codes and output
| Exit code | Meaning |
|---|---|
0 |
Success (including graceful server shutdown and help) |
1 |
Any runtime failure: database errors (including an unparseable stored audit time), token not found, token already inactive, listen failure, server failure, shutdown failure |
2 |
Usage errors and configuration errors |
- Command results go to stdout (for
help, the usage text); nothing else is ever written to stdout. - Errors are a single line on stderr prefixed with
dpsapi:; it is the last line written to stderr, except for usage errors, where the usage text follows it. The supplied token or audit argument is never echoed. - Usage errors also print the usage text to stderr, after the error line. Usage errors do not open or create the database; neither does
help.
dpsapi: token not found
dpsapi: token already inactive
dpsapi: usage error: unknown command
dpsapi: usage error: help takes no arguments
dpsapi: usage error: missing token subcommand
dpsapi: usage error: unknown token subcommand
dpsapi: usage error: token list takes at most one argument
dpsapi: usage error: unknown token list argument
dpsapi: usage error: token new takes at most one argument
dpsapi: usage error: token delete requires exactly one argument
dpsapi: usage error: audit takes at most one argument
dpsapi: usage error: invalid audit argument
Usage text (printed to stderr after a usage error and to stdout by help, byte for byte the same):
usage:
dpsapi start the HTTP server
dpsapi help print this usage text
dpsapi token list [all] list the active tokens, or every token with "all"
dpsapi token new [description] create a token and print it
dpsapi token delete <token> deactivate a token
dpsapi audit print the latest 10 audit rows
dpsapi audit <n> print the latest n audit rows
dpsapi audit <n><unit> print the audit rows of the last n units (s, m, h, d, w)
dpsapi audit all print every audit row
Commands, subcommands and the all arguments (of token list and audit) are case-sensitive. Use dpsapi help to print the usage text: there is no -h/--help flag (-h, --help and Help are unknown commands).
HTTP API
GET /api/v1/containers
Authorization: Bearer <token>
Success: 200 OK, Content-Type: application/json, body = JSON array of Docker Engine container-summary objects (the GET /containers/json objects, Id, Names, Image, State, ...) for running containers only; [] when none are running. Bodies have an explicit Content-Length and no trailing newline.
| Status | Body | When |
|---|---|---|
401 |
{"error":"unauthorized"} |
Missing/empty/malformed Authorization, more than one Authorization header, unknown token, inactive token. Also sets WWW-Authenticate: Bearer. |
404 |
{"error":"not found"} |
Any path other than exactly /api/v1/containers (including /api/v1/containers/, //api/v1/containers, /api/v1/./containers, different case). Paths are not cleaned and never redirected. |
405 |
{"error":"method not allowed"} |
Any method other than GET (including HEAD) on /api/v1/containers. Also sets Allow: GET. |
500 |
{"error":"internal server error"} |
Token lookup failed, audit insert failed, Docker Engine request failed, or response serialization failed. |
All error responses have Content-Type: application/json. Internal error details are only logged.
Authentication rules:
- Exactly one
Authorizationheader of the formBearer+ one space + token. The scheme is ASCII case-insensitive (bearer,BEARER); the token is compared byte-exact against the database (case-sensitive, no trimming: an extra space before the token makes it a different, unknown token). Leading and trailing whitespace of the whole header value is removed by HTTP parsing itself (RFC 9110), soBearer <token>authenticates. - Processing order: authentication, then the audit row is inserted, then Docker is queried. An authenticated request is therefore audited even when Docker fails; unauthenticated,
404and405requests are never audited. - The audited origin is the IP address of the TCP connection.
X-Forwarded-For,Forwarded,X-Real-IPand similar headers are ignored. - Query strings are ignored (
/api/v1/containers?x=1is the same endpoint). Percent-encoded unreserved characters are decoded before matching (/api/v1/%63ontainersmatches); a percent-encoded slash is not a path separator (/api/v1%2Fcontainersis404).
Requests rejected by Go's HTTP server before they reach the application (malformed request lines, headers larger than 1 MiB → 431, OPTIONS *) get Go's default plain-text responses and are not logged.
Example:
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/containers
Logging
Logs are written to stderr with Go's log/slog text handler, one record per line, timestamps in UTC with millisecond precision:
time=2026-10-05T20:03:15.908Z level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=200 duration=1.994499ms origin=127.0.0.1
Levels, from most to least severe: ERROR, WARN, INFO, DEBUG, TRACE. LOG_LEVEL sets the minimum emitted level.
ERROR: request failures (token lookup failed,audit insert failed,docker request failed,response serialization failed). With no errors the server is silent at this level.WARN: messages from Go's HTTP server (e.g. TLS/accept problems).INFO:server listening(withaddr,version,commit),server stopped, and for every request reaching the application, including401/404/405, the messagethis request has been madewithmethod,path,status,duration(Go duration format) andorigin. Successful CLI commands log nothing atINFO.DEBUG: configuration resolution, dispatch (mode=server|cli,cli dispatch command="token ..."(command="token list"with or withoutall),cli dispatch command=audit limit=<n>|window=<duration>|all=true,cli dispatch command=help), database open/schema initialization/close, route registration, Docker client creation, authentication result class (result=missing_header|empty_header|multiple_headers|bad_scheme|empty_token|unknown_token|inactive_token|lookup_error|ok), audit insertion, Docker request start/finish, token created/deactivated, audit window start (audit window resolved since=...), audit result count (audit rows listed rows=<n>), shutdown.TRACE:function entry/function exitrecords (func=...) for the application functions on the execution path, and row-level database records (row inserted,row read,row updated,row not found,row already inactive,container listed).auditlogs arow readrecord for everyoperationsrow it reads, including rows outside the selection.
Token secrets are never logged. Records that concern a token carry token_id, the first 8 hex characters of the token's SHA-256 digest. The Authorization header and raw request headers are never logged. Descriptions are logged at TRACE. Full tokens appear only on stdout, from token new, token list [all] and audit.
Docker
The container must have the Docker socket mounted; the application talks to unix:///var/run/docker.sock directly and never runs a docker executable. The SQLite database must live on a persistent mount.
docker run -d --name dpsapi \
-p 127.0.0.1:8080:80 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /srv/dpsapi:/data \
-e DB_PATH=/data/dpsapi.db \
git.bjphoster.com/source/dpsapi:latest
Optional: -e HTTP_ADDR=0.0.0.0 -e HTTP_PORT=80 -e LOG_LEVEL=INFO.
The process runs as root inside the container so it can open the socket and write to the data directory.
Token management and audit inspection inside the running container (docker exec inherits the container's environment, so DB_PATH and LOG_LEVEL apply):
docker exec dpsapi /app help
docker exec dpsapi /app token new "ci pipeline"
docker exec dpsapi /app token list
docker exec dpsapi /app token list all
docker exec dpsapi /app token delete <token>
docker exec dpsapi /app audit
docker exec dpsapi /app audit 50
docker exec dpsapi /app audit 1d
docker exec dpsapi /app audit all