# Admin console

The admin listener serves a web console next to the REST API and MCP.
You sign in with a passkey; there are no passwords. Everything the
console does goes through the same service layer as the API and MCP, so
the audit log and error objects are the same.

| Section | What it shows or does |
| --- | --- |
| Storage | Disk usage of `[server].root`, tiered-storage state, per-user usage against quota |
| Users | Create, edit, and delete API-managed users; set passwords |
| Sessions | Live FTP, FTPS, and SFTP sessions; end one |
| Logs | The server log and the audit log, with filters; audit chain check |
| Admins | Invite and remove admins |
| Account | Your passkeys, personal access tokens, and connected apps |
| Connect an LLM | The MCP URL and how to sign a client in ([Agents and MCP](/docs/llm.html#connect-an-llm)) |

## Turn it on

The console needs an origin, which is a URL whose host the browser
trusts for passkeys.

**On the server itself.** A loopback bind needs no extra settings. The
console is at `http://localhost:<port>`:

```toml
[admin]
bind = "127.0.0.1:8080"
```

Reach it from your workstation with a port forward. Any local port
works, so pick a free one:

```sh
ssh -L 8080:127.0.0.1:8080 files.example.com
aws ssm start-session --target i-0123456789abcdef0 \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["8080"],"localPortNumber":["8080"]}'
```

Open `http://localhost:8080`. A request by IP address (`127.0.0.1`) is
redirected to `localhost`, because passkeys cannot use IP addresses.

**On a public host.**

```toml
[admin]
bind = "0.0.0.0:443"
tls = true                                  # uses the [ftps] certificate
public_url = "https://files.example.com"    # a DNS name, never an IP address
```

| Setting | Why |
| --- | --- |
| `tls = true` | Serves HTTPS with the `[ftps]` certificate. The certificate must name the host in `public_url` |
| `public_url` | The console origin. Its host is the passkey relying-party id and the OAuth issuer |
| DNS name | Passkeys cannot be bound to an IP address |

With a non-loopback bind and no `public_url`, the console is off and
only the bearer API is served. `siftpd --check-config` says so.

Changing the host in `public_url` invalidates every passkey. Recover
each admin with `admin-invite --recover` (below).

`[admin].ui = false` turns the console off and keeps the API.

## First admin

An invite link creates the first admin. Run this on the server:

```sh
sudo -u siftp siftpd admin-invite alice
```

```json
{"admin":"alice","recover":false,"expires_unix":1761700000,"url":"https://files.example.com/admin/#/invite/..."}
```

Open the URL, then register a passkey. The link is valid for 24 hours
and works once. It is stored as a hash, so a lost link cannot be
shown again: run the command again, and the new link replaces the old
one. (In the console, a second invite for a pending name is refused
until the first is revoked.)

Run it as the `siftp` user, not as root. The command writes the
accounts file, and a root-owned file is one the daemon cannot read. The
command checks the owner of the accounts file's directory and refuses
to run as anyone else.

## Recovery

An admin who lost every passkey, or whose host changed, gets a new link:

```sh
sudo -u siftp siftpd admin-invite alice --recover
```

The link adds a passkey to the existing admin `alice`. Without
`--recover`, an invite for a name that already exists is refused, and
`--recover` for an unknown name returns `not_found`.

## More admins

Any signed-in admin can invite another from Admins. The link is shown
once. Removing an admin ends their sessions, tokens, connected apps, and
pending invites. The last admin cannot be removed.

## Sessions

| Limit | Value |
| --- | --- |
| Idle | 30 minutes |
| Absolute | 12 hours (2 hours on `http://localhost`) |
| Restart | Signs everyone out; sessions are in memory |

Sessions are cookies bound to the console origin: `HttpOnly`,
`SameSite=Strict`, and `__Host-` prefixed on HTTPS. Changes need the
console's exact `Origin` header.

## The fresh passkey check

These actions need a passkey assertion from the last 5 minutes. A stale
session gets `403` with `X-Siftp-Step-Up: required`, and the console
asks for your passkey again.

| Action | Why |
| --- | --- |
| Invite or remove an admin | Hands out or takes away access |
| Add or remove a passkey | A stolen session could add its own passkey and outlast the session |
| Create a personal access token | A token outlives the session |
| Approve an OAuth client | Gives an outside service access |

An admin's last passkey cannot be removed. Removing a passkey ends the
sessions it signed in.

## Personal access tokens

Create one in Account. It is shown once and looks like
`siftp_pat_<id>_<secret>`. Only its SHA-256 is stored.

| Field | Values |
| --- | --- |
| Scope | `siftp:read` (GET routes and read-only MCP tools, the default) or `siftp:admin` (also changes) |
| Lifetime | 1 to 365 days (default 90) |

Use it as a bearer token on `/api/v1/*`, `/metrics`, and `/mcp`:

```sh
curl -H "Authorization: Bearer $SIFTP_PAT" https://files.example.com/api/v1/storage
```

Tokens are the way for scripts and Prometheus to reach a public console.
`[admin].token` still works, but only from loopback peers while the
console is public. A wrong or expired token returns `401` and does not
count toward login bans: tokens are 256-bit secrets, and an LLM
provider's shared address must not get banned.

## What tokens and LLM clients cannot do

Only a passkey session manages credentials. A personal access token, an
OAuth token, and `[admin].token` get `permission_denied` for:

- admins, invites, and passkeys
- personal access tokens and connected apps
- a `virtual_root` outside `[server].root`, or an `s3://` root (tokens and
  LLM clients only)

No credential can set a user's root to a directory that holds siftp's
own files (config, accounts file, user store, revoked-key list, audit
log, TLS and host keys), or one containing `..`. No MCP tool manages
admins or credentials.

## The accounts file

`[admin].accounts_file` (default `/var/lib/siftp/admins.toml`, mode
0600) holds admins, passkey public keys, invites, tokens, and OAuth
grants. Codes and tokens are stored only as SHA-256 hashes.

- The daemon and `admin-invite` lock `admins.toml.lock`, re-read, and
  replace the file atomically, so neither loses the other's changes.
- A file that does not parse turns off console sign-in, personal access
  tokens, and OAuth grants. It is never read as "no admins". Fix or
  restore the file, then reload (`systemctl reload siftpd`).
- Back it up with the user store (`users.toml`). Restoring an old copy
  brings back revoked tokens and removed admins.

## Audit `actor`

Admin changes (users, passwords, sessions, reload, and console accounts)
carry an `actor` field. `remote` is the caller's address.

```json
{"ts":1761600000,"user":"erin","action":"USER_CREATE","path":"","result":"ok","remote":"203.0.113.9:50122","actor":"ui:alice","seq":97,"chain":"4c1a…"}
```

| `actor` | Who acted |
| --- | --- |
| `ui:<admin>` | A passkey session in the console |
| `pat:<admin>/<token name>` | A personal access token |
| `oauth:<admin>/<client host>` | An OAuth client, such as `oauth:alice/claude.ai` |
| `token` | `[admin].token` |
| `stdio` | `siftpd mcp-stdio` |
| `cli` | `siftpd admin-invite`; for a new admin, the creator (`ui:<admin>` or `cli`) |

Console sign-ins are `ADMIN_LOGIN`; a wrong `[admin].token` is
`ADMIN_AUTH`. [SPEC §8.5](/spec.html) lists every `action`.
`admin-invite` itself writes no audit line (the chain has one writer).
The invite appears as `ADMIN_CREATE` or `PASSKEY_ADD` with actor `cli`
when it is used.

## See also

- [Agents and MCP](/docs/llm.html#connect-an-llm): connect Claude, ChatGPT, Cursor, Codex
- [Security](/docs/security.html#deploying-the-admin-api): TLS, CSP, ALPN
- [Operations](/docs/operations.html#backups): backups
