Admin API and console
[admin]
listen = "0.0.0.0:8080" # file API, file explorer
control_listen = "0.0.0.0:8081" # admin console, /admin, /metrics, probes
bearer_token = "changez-moi-en-production"
[api]
enabled = true
prefix = "/api/v1/files"
The keys: reference.
One door or two
| Configuration | Listener | What it serves | Runtime |
|---|---|---|---|
| without API | listen | console, /admin/*, /metrics, probes | admin (two admin-rt threads) |
API, without control_listen | listen | everything, and the API | main, shared with transfers |
control_listen | control_listen | /admin/*, /, /ui/*, /metrics, /health, /livez, /readyz | admin |
listen | file API, file explorer, /api/docs and /api/openapi.json | main | |
[server] probes_listen | probes_listen | /livez, /readyz, /health and /metrics, over plain HTTP, and nothing else; they leave listen and control_listen | admin |
With the file API, set control_listen: bans, kicks, /metrics and
probes stay reachable when transfers saturate the server. The Helm chart
does it (service.control.port). probes_listen gives probes and
Prometheus a port without TLS or console; without [admin], it is the only
HTTP listener (Kubernetes). The console
reads its health on /admin/health, served next to it in every case.
Authenticating
Each protected route requires Authorization: Bearer <token>.
| Token | Identity | Permissions |
|---|---|---|
a session token, returned by POST /admin/login (Console sessions) | the local account | those of the admin roles named by its authorities, reread at each request |
a JWT verified by [auth.jwt] | its name | those of the admin roles named by its roles claim (authorities_path) |
equal to bearer_token | <static-token> | all; a fallback, that allow_static_token = false disables (the Helm chart sets it) |
An admin role names permissions; an authority that carries its name grants them.
[[admin.roles]]
name = "support"
permissions = ["overview", "sessions", "logs"]
[[admin.roles]]
name = "admins"
permissions = ["overview", "sessions", "kick", "bans", "unban", "config", "logs", "audit", "revoke", "grant"]
The names of admin roles and those of [[roles]] are distinct. An account
that carries both kinds opens the console and the file doors; an account
that carries only admin roles opens only the console. The authorization
service never receives an admin role name, and no admin token opens a
file. Failures count in [admin.ban]
(Bans).
| Permission | Routes |
|---|---|
| none (any accepted token) | GET /admin/me, POST /admin/logout; POST /admin/session/renew (session token) |
overview | GET /admin/resources, GET /admin/status, GET /admin/health, GET /admin/doors |
sessions | GET /admin/sessions; GET /admin/events (session events) |
kick | DELETE /admin/sessions/{id} |
config | GET /admin/config, GET /admin/sessions/{id}/roles |
bans | GET /admin/bans; GET /admin/events (ban events) |
unban | DELETE /admin/bans/{protocol}/{ip} |
logs | GET /admin/logs/app, GET /admin/logs/app/stream |
audit | GET /admin/logs/audit, GET /admin/logs/audit/stream (each read is audited) |
revoke | GET /admin/revocations, POST /admin/revocations |
grant | GET, POST /admin/grants, DELETE /admin/grants/{id}, GET /admin/grants/candidates (accounts and mounts also require config, seen names sessions): a file role granted for a time ([admin.grants], reference); at until or on revocation, the SFTP sessions opened by the access and the REST transfers in progress under its mount are cut (on another instance: at its next reread); GET /admin/events (access events) |
| public | POST /admin/login, GET /admin/login, /, /ui/*, /health, /livez, /readyz, /metrics; /api/docs, /api/openapi.json with api.openapi |
Sessions
{"total": 1, "offset": 0, "limit": 50, "server_time": "2026-10-07T14:42:10.125Z", "inactivity_timeout_secs": 600,
"sessions": [{"session_id": "550e8400-e29b-41d4-a716-446655440000", "username": "alice", "remote_addr": "192.168.1.42:54321", "auth_method": "password",
"mounts": [{"mount_path": "/", "backend": "disque", "home_dir": "/alice", "acl": [{"path": "/", "rights": ["read", "write", "list", "delete", "rename"], "recursive": true}]}],
"connected_at": "2026-10-07T14:30:00Z", "idle_since": null, "last_sftp_op_at": "2026-10-07T14:41:25.402Z", "last_traffic_at": "2026-10-07T14:42:05.871Z",
"bytes_read": 1048576, "bytes_written": 524288, "sftp_ops": 412, "has_active_transfer": false, "pod_name": "craft-file-gate-0", "authorities": ["utilisateurs"],
"client_version": "SSH-2.0-OpenSSH_9.6", "algorithms": {"kex": "curve25519-sha256", "host_key": "ssh-ed25519", "cipher": "chacha20-poly1305@openssh.com",
"mac_client_to_server": "hmac-sha2-256", "mac_server_to_client": "hmac-sha2-256"}, "user_key": null, "weak_algorithms": [], "type": "sftp"}]}
| Field | Meaning |
|---|---|
mounts | one object per mount: mount_path, backend (its name), expanded home_dir, acl relative to the mount |
last_sftp_op_at | the last SFTP request, refusals included, keepalives excluded |
last_traffic_at | the last bytes from the client, keepalives included; the cut by inactivity_timeout_secs starts from there |
has_active_transfer | an open file; holds the shutdown during the grace period |
user_key | the SSH key: key_type, fingerprint, signature_algorithm |
weak_algorithms | the negotiated algorithms judged weak |
The list also carries REST transfers (upload, download) in progress
for long_request_threshold_secs (30 s by default, 0: all, reloaded at
runtime): "type": "rest" (an SFTP session has "sftp"), session_id,
username, remote_addr, method (PUT, GET), direction (upload,
download), path (the path seen by the user), mounts (mount_path,
backend), connected_at (its start), bytes, bytes_read,
bytes_written, bytes_per_sec (the average since the start).
| Request | Effect |
|---|---|
GET /admin/sessions?offset=&limit=&scope= | limit 50 by default, 1000 at most; scope=cluster: also the sessions of each instance, field instance, and peers (what each peer answered); local by default |
GET /admin/sessions/{id}/roles | the mounts as resolved at connection (effective.mounts, with roles, max_file_mb, create_home, hidden_stores) and the current definition of each role |
DELETE /admin/sessions/{id} | cuts the session ({"status": "disconnected"}) and removes it from the list and from the max_sessions_per_user count; a REST transfer is cut as if by a departed client, audit line reason="session ended: admin_kick"; held by another instance, that instance cuts it (instance) |
GET /admin/doors | the configured doors: name, readiness (accepting, not_accepting, hosted), started, sessions_open, activity |
Logs and streams
With [log] dir, GET /admin/logs/{app|audit} returns the last entries of
the current file, filtered by the server. See Logs.
Parameters: lines (500, 5000 at most; 4 MiB scanned at most), level
(ERROR to TRACE), since and until (RFC 3339), q (text, 256 bytes),
field=key:value (16 at most). 3 concurrent reads at most.
SSE streams (text/event-stream) follow activity live:
| Stream | Events |
|---|---|
GET /admin/events | session.opened, session.closed, session.kicked, session.revoked, ban.added, ban.lifted, lagged |
GET /admin/logs/{app|audit}/stream | entry, one per new retained entry; follows a rotation |
At most 8 open streams, 2 per identity; a stream ends at the exp of its
token or on its revocation, at the latest after 15 minutes.
Probes and health
| Route | Answers | Use |
|---|---|---|
/livez | 200 {"status": "alive"} as long as the process lives | liveness probe; craft-file-gate healthcheck |
/readyz | 200 if the SFTP port accepts and the main runtime takes a task within 500 ms, 503 otherwise and from the shutdown signal on | readiness probe |
/health | 200 always; status ok or degraded (authorization service unreachable), checks.auth_service, checks.data_plane (verdict of /readyz), nothing else | monitoring |
/admin/health | /health, with the version, active sessions and configured backends; permission overview | the console |
/admin/status | ready, degraded or not_ready, with each check (sftp, data_runtime, auth_service, jwks) and its detail | the console banner |
The console
http://serveur:8081/: a page that calls /admin/*. It loads nothing from
a third party. Its login: Console sessions.
| Tab | Content | Permission |
|---|---|---|
| Overview | status, version, sessions, authorization service | overview |
| Metrics | /admin/status banner, five-minute tiles, backends, top users | overview (top users: sessions) |
| Instances | with [cluster], each instance: status, version, sessions, bans, certificate expiry, health | overview |
| Sessions | one row per SFTP session and per long REST transfer, Type column (sftp, rest); mounts, activity, kick; with a peer, each instance (Instance column) | sessions, kick |
| Configuration | backends (secrets masked, storage clock), roles, host keys | config |
| Bans | current bans, lifting; with a peer, each instance (Instance column) | bans, unban |
| Logs | log and audit trail, filters, live follow; with [log] dir | logs, audit |
| Revocations | revoke the sessions of an account, those in force; when the door connects accounts | revoke |
| Temporary access | grant a file role for a time, live accesses and those ended in the last day, revocation; Temporary access; the Sessions view shows the accesses of a session | grant |
| Hash tools | hash-password and verify-password in WebAssembly in the page: the password does not leave the browser | none |
- Filters and sorts (
ip:,user:,role:,backend:,type:) live in the URL:/#sessions?q=user:bob&sort=-last_op. The token never appears there. - The page and its files (
/ui/*) carry a strict CSP (default-src 'none', everything from the same origin,'wasm-unsafe-eval'); a reverse proxy that sets its own allows at least as much.
What you will see
| When | Line |
|---|---|
| startup | INFO starting admin API server (HTTP) (or (HTTPS)) |
startup with [admin.tls] | INFO admin TLS certificate is valid, fields not_after, remaining_days; /metrics publishes the date |