proftpd-agent

HTTP sidecar for proftpd to allow remote access to ftpdctl and ftpwho

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.

Repository

proftpd-agent

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 4 hours ago
  • August 17, 2026
License

Links
Synced at

Mon, 17 Aug 2026 13:40:20 GMT

Languages