proftpd-agent
proftpd-agent
A small HTTP sidecar for ProFTPD that wraps ftpwho and ftpdctl, so applications running in a separate container can query sessions and kick users over the network instead of shelling out to binaries that only exist inside the ProFTPD container.
See docs/deployment.md for how to run this alongside ProFTPD in a single container (s6-overlay setup).
Authentication
Every endpoint except GET /health (and /health/) requires an API key.
Set it explicitly via the PROFTPD_AGENT_API_KEY environment variable. If unset, the agent generates a random key at startup and prints it once to stdout - the process will not accept any authenticated request without a key, generated or provided.
Send the key as a bearer token:
Authorization: Bearer <key>
Configuration
All configuration is via environment variables.
| Variable | Default | Notes |
|---|---|---|
PROFTPD_AGENT_API_KEY |
(generated) | see Authentication |
PROFTPD_AGENT_BIND_HOST |
0.0.0.0 |
must be reachable from wherever the API is called from |
PROFTPD_AGENT_PORT |
8080 |
|
PROFTPD_AGENT_FTPWHO_BIN |
ftpwho |
resolved via PATH if not an absolute path |
PROFTPD_AGENT_FTPDCTL_BIN |
ftpdctl |
resolved via PATH if not an absolute path |
PROFTPD_AGENT_COMMAND_TIMEOUT |
5s |
accepts ms/s/m suffixes, e.g. 500ms, 2m |
LOG_LEVEL |
info |
see Logging |
Logging
Built on Crystal's standard Log module with a custom JSON backend (ProftpdAgent::JsonLogBackend), so any library that logs through Log (including Crystal's own stdlib) is captured in the same format — nothing falls back to a separate, unstructured log stream. One JSON object per line on stdout:
{"level":"info","ts":"2026-08-17T10:08:00.415Z","source":"http","msg":"request","version":"0.1.0","data":{"method":"GET","path":"/v1/sessions","status":200,"duration_ms":7.792041,"client":"10.4.11.81:56046"}}
| Field | Notes |
|---|---|
level |
debug, info, warn, or error |
ts |
UTC, millisecond precision |
source |
subsystem emitting the entry, e.g. app, http, ftpd |
msg |
short description of the event |
version |
agent version |
data |
event-specific fields; native JSON types (numbers/bools stay numbers/bools) |
Set LOG_LEVEL (e.g. LOG_LEVEL=debug, LOG_LEVEL=warn) to filter by severity, via Crystal's built-in Log.setup_from_env. Default is info.
Every HTTP request is logged once, after it completes, with data.method/data.path/data.status/data.duration_ms/data.client (the remote address:port, or - if unavailable) — level is info for 2xx/3xx, warn for 4xx, error for 5xx.
Any unhandled exception is caught, logged once as source: "app", msg: "unhandled exception" (with data.exception_class, data.exception_message, and data.backtrace), and still produces a normal http/request log entry at error level — no request silently disappears from the logs, and the client always gets the standard JSON error envelope (500, internal_error) rather than a raw non-JSON response.
One deliberate exception: if PROFTPD_AGENT_API_KEY isn't set, the generated key is printed as plain text on startup, not through this JSON logger — JSON logs commonly get shipped verbatim into log aggregation platforms, which would turn a one-time secret into an indexed, long-retained field.
Response envelope
All JSON responses use one of two shapes.
Success:
{ "data": ... }
Error:
{ "error": { "code": "session_not_found", "message": "no session with pid 1982674" } }
data may be an object or an array depending on the endpoint, documented below. error.code is a stable machine-readable string; error.message is human-readable and may change wording between versions.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
none | liveness probe |
GET |
/v1/version |
key | agent version and configured binary paths |
GET |
/v1/sessions |
key | active FTP sessions (ftpwho -o json) |
GET |
/v1/sessions/:id |
key | a single session by pid |
POST |
/v1/sessions/kick |
key | disconnect all of a user's sessions (ftpdctl kick user <name>) |
GET |
/v1/services |
key | configured vhost/port status (ftpdctl status all) |
GET /health
Not wrapped in the standard envelope, since infra tooling (Docker HEALTHCHECK, Kubernetes probes) expects a minimal, stable body.
{ "status": "ok" }
GET /v1/version
{
"data": {
"version": "0.1.0",
"ftpwho_bin": "/usr/sbin/ftpwho",
"ftpdctl_bin": "/usr/sbin/ftpdctl"
}
}
GET /v1/sessions and GET /v1/sessions/:id
GET /v1/sessions returns an array under data; GET /v1/sessions/:id returns a single object under data, or 404 with error.code: "session_not_found" if no connection has that pid. A non-integer :id returns 400 with error.code: "invalid_request".
{
"data": [
{
"pid": 1982674,
"user": "ftptest",
"protocol": "sftp",
"class": "whitelist",
"remote_name": "10.66.64.14",
"remote_address": "10.66.64.14",
"local_address": "10.240.0.81",
"local_port": 22,
"connected_at": "2026-08-17T08:29:07Z",
"idle": true,
"idle_since": "2026-08-17T08:29:07Z",
"location": "/",
"authenticating": false
},
{
"pid": 1982705,
"protocol": "ssh2",
"class": "whitelist",
"remote_name": "10.66.64.14",
"remote_address": "10.66.64.14",
"local_address": "10.240.0.81",
"local_port": 22,
"connected_at": "2026-08-17T08:29:14Z",
"idle": true,
"authenticating": true
}
]
}
The second example above is a connection still completing authentication — note user, location, and idle_since are absent entirely rather than null, matching how ftpwho itself omits fields that don't apply yet.
| Field | Type | Notes |
|---|---|---|
pid |
integer | connection pid, as reported by ftpwho; the :id path param |
user |
string, nullable | absent until authentication completes |
protocol |
string | e.g. sftp, ssh2, ftp |
class |
string | ProFTPD ACL class name |
remote_name |
string | remote hostname as resolved by ftpwho |
remote_address |
string | remote IP address |
local_address |
string | local IP address the connection arrived on |
local_port |
integer | local port the connection arrived on |
connected_at |
string | RFC 3339 timestamp |
idle |
boolean | |
idle_since |
string, nullable | RFC 3339 timestamp; not always present even when idle is true |
location |
string, nullable | current working directory; absent until authentication completes |
authenticating |
boolean | true while the connection hasn't completed authentication yet |
POST /v1/sessions/kick
ftpdctl only supports kicking by user, host, class, or all - there is no way to disconnect a single connection by id. This endpoint therefore takes a username in the request body and disconnects every session for that user, not one specific connection:
{ "user": "alice" }
{ "data": { "user": "alice", "kicked": true } }
Returns 400 with error.code: "invalid_request" if user is missing, empty, or contains characters outside [\w@-] (word characters plus - and @), or starts with - — user is passed positionally to ftpdctl with no shell involved, so a leading - is rejected to stop it from being read as a flag by ftpdctl's own argument parser. A nonexistent user is not distinguished from other command failures — ftpdctl's exit code/output for "no such user" hasn't been confirmed, so it currently surfaces as command_failed (502) like any other ftpdctl error.
GET /v1/services
Parsed from ftpdctl status all, e.g.:
ftpdctl: status: 10.211.55.2#2021 UP
ftpdctl: status: 127.0.0.1#2022 UP
{
"data": [
{ "address": "10.211.55.2", "port": 2021, "state": "up" },
{ "address": "127.0.0.1", "port": 2022, "state": "up" }
]
}
state is the lowercased value as reported by ftpdctl (typically up or down) and is not restricted to an enum, since the full set of possible states isn't documented upstream.
Errors
error.code |
HTTP status | Meaning |
|---|---|---|
unauthorized |
401 | missing or invalid API key |
invalid_request |
400 | malformed/missing request body or parameters |
session_not_found |
404 | no connection with the given pid |
command_failed |
502 | ftpwho/ftpdctl failed to start or exited non-zero — if this happens on every request, see Permissions |
command_timeout |
504 | ftpwho/ftpdctl did not complete within PROFTPD_AGENT_COMMAND_TIMEOUT |
internal_error |
500 | an unhandled error occurred; logged with exception details, see Logging |
ftpwho/ftpdctl output is capped at 4 MiB per stream (stdout and stderr separately) to bound memory against a misbehaving or unexpectedly verbose binary; a truncated read is logged as a warning but does not itself fail the request — parsing continues against whatever was captured. A connection entry from ftpwho that's missing an expected field is skipped individually (also logged as a warning) rather than failing the whole /v1/sessions response.
proftpd-agent
- 0
- 0
- 0
- 0
- 2
- about 4 hours ago
- August 17, 2026
Mon, 17 Aug 2026 13:40:20 GMT