# admin
Serves the embedded web admin UI and the configuration REST API. Lives in the
`pingap` binary (`src/plugin/admin.rs`) because it needs the configuration
manager, the certificate/upstream providers and the restart machinery.
- **Step:** `request` (fixed)
- **Registered as:** `admin`
Most people never declare this plugin by hand — the `--admin` command line flag
builds an equivalent configuration. Declaring it explicitly is what you do when
the admin UI should live on an existing server behind a path prefix.
## Configuration
| `category` | string | — | Must be `admin`. |
| `path` | string | `""` | URL prefix the admin UI is mounted at. A trailing `/` is stripped. |
| `authorizations` | string[] | `[]` | Base64 of `user:password`, both non-empty; anything else is rejected. **With this, `readonly_authorizations` and both lists of tokens empty, there is no authentication at all.** |
| `readonly_authorizations` | string[] | `[]` | The same, for accounts that can look and not change anything. |
| `tokens` | string[] | `[]` | API tokens, each as `name:<sha256 of the token in hex>`. |
| `readonly_tokens` | string[] | `[]` | The same, for tokens that can read and not change anything. |
| `max_age` | duration | `2d` | How long a signed token is good for, counted from the time it carries. |
| `ip_fail_limit` | int | `10` | Failed attempts per IP before that IP is blocked for 5 minutes. |
## Via the command line
```bash
pingap -c /opt/pingap/conf --admin=pingap:123123@127.0.0.1:3018
# or, mounted under a prefix on an existing listener
pingap -c /opt/pingap/conf --admin=pingap:123123@0.0.0.0:80/pingap
```
Equivalent environment variables: `PINGAP_ADMIN_ADDR`, `PINGAP_ADMIN_USER`,
`PINGAP_ADMIN_PASSWORD`. Prefer them where other users of the machine can
see the process list: the command line of a process is public, its
environment is not. A graceful restart keeps it that way - what came from the
environment (these three, and `PINGAP_CONF`) is inherited by the replacement
process and not written out as arguments.
The other accounts go after the address, as parameters, each as often as
there are accounts of its kind:
```bash
pingap -c /opt/pingap/conf \
--admin="pingap:123123@127.0.0.1:3018?readonly=bob:s3cret&token=ci:5e88...d8&readonly_token=monitor:9c2b...7d"
```
| `readonly` | An account that may look and not change: `user:password`, or the base64 of it. |
| `token` | An API token that may write: `name:<sha256 of the token>`. |
| `readonly_token` | An API token that may look. |
| `max_age` | How long a login holds (default `2d`). |
They are the `readonly_authorizations`, `tokens` and `readonly_tokens` of the
plugin, described below, and are checked the same way. `PINGAP_ADMIN_ADDR`
takes them as well. In a password written here `&`, `#` and `%` are
percent-encoded (`%26`, `%23`, `%25`); everything else, a `+` included, is
taken as it is written.
The credentials are `user:password`, or the base64 of `user:password` in place
of the user. A user without a password that is not such a base64 value
(`--admin=root@127.0.0.1:3018`) is an error and the process does not start.
Characters with a meaning in a URL are percent-encoded (`p%40ss` for `p@ss`),
and the password is what they decode to.
## As a plugin
```toml
[plugins.admin]
category = "admin"
path = "/pingap"
authorizations = ["cGluZ2FwOjEyMzEyMw=="] # pingap:123123
max_age = "1h"
ip_fail_limit = 5
[locations.admin]
path = "/pingap"
plugins = ["admin"]
weight = 2000
[servers.main]
addr = "0.0.0.0:80"
locations = ["admin", "app"]
```
## Authentication
The API does not use HTTP Basic. Each request carries
```
Authorization: <token>:<unix-seconds>
token = hex(sha256("<user>:<password>:<unix-seconds>"))
```
`<unix-seconds>` must be no older than `max_age` by the proxy's clock, and no
more than five minutes ahead of it (or `max_age`, where that is less). The
five minutes are for two clocks that are not quite the same. With the
browser's more than that ahead of the proxy's - or the proxy's that far
behind - a login is refused whatever the password: the `401` then says so in
its body, which the login page shows, and it is not counted as a failed login,
since no credentials were compared. The time used to be allowed `max_age`
ahead as well as behind, so a token made for a time two days from now was
good for four. The token is compared in constant time, and the web UI
computes it for you after login.
There are two kinds of path under the admin prefix. `/api` and everything
below it is the API and always requires the token. Every other path is a file
of the embedded UI, served without authentication so the login screen can
load, and answered `404` when there is no such file. An API route is only
reachable under `/api`: `/configs/...` without the prefix is not routed to it.
After `ip_fail_limit` failed logins an IP is refused with `403 Forbidden, too
many failures` for 5 minutes. A failed login is a request to the API whose
`Authorization` does not check out; a request without one is answered `401`
and not counted, so a page that keeps polling after its token ran out does not
lock its user out. One refused for the time it carries, too old or ahead of
the clock, is not counted either. The lock stands in front of the API only: the files of the
UI still load, and on a server shared with an application the paths outside
the admin prefix are not affected.
## Roles, API tokens and the audit line
```toml
[plugins.admin]
category = "admin"
authorizations = ["YWxpY2U6czNjcmV0"] # alice:s3cret - may change things
readonly_authorizations = ["Ym9iOnMzY3JldA=="] # bob:s3cret - may look
tokens = ["deploy:4f0a...e1"] # name:sha256(token)
readonly_tokens = ["monitor:9c2b...7d"]
```
- **Read-only** accounts and tokens get every `GET` of the API and none of
what changes something: `POST` and `DELETE` under `/configs` (an import
included) and `POST /restart` are answered `403 Forbidden, this account is
read-only`. What they can read is everything the admin shows, the
credentials inside the configuration included: it is a role that can not
break anything, not one that is kept from secrets. The pages leave out what
such an account can not do - saving, removing, creating, importing,
restoring a version, restarting - and say that the account is read-only.
That is for the reader's sake: what such an account may do is decided by
the API, whatever a page shows.
- A user name is in one of the two lists, and the name of a token is there
once. With read-only accounts or tokens and none that may write, the
plugin is refused: nobody could change anything.
- **An API token** is for what is no person at the login page - a deploy
script, a monitor:
```bash
token=$(openssl rand -hex 32) printf %s "$token" | shasum -a 256 curl -H "Authorization: Bearer $token" http://127.0.0.1:3018/api/configs/upstream
```
The configuration has the digest and not the token, so reading it gives
nobody the token; it is as hard to guess as it is long, which is why it is
made by a tool and not thought up. Taking its entry out revokes it, with
the reload of the plugin and without touching the passwords. A token that
is not known is a failed login like a wrong password, counted by
`ip_fail_limit`.
- **Every change leaves a line** in the application log, also one that was
refused:
```text
INFO main::admin: admin audit user="alice" ip="10.0.0.7" method="POST" path="/configs/upstream/api" status=204
INFO main::admin: admin audit user="token:monitor" ip="10.0.0.9" method="POST" path="/restart" status=403
```
`user` is the account, `token:<name>` for a token, and `anonymous` on an
admin without credentials; `ip` is the address logins are counted by. What
was changed is in what the reload writes - the log line `current config
diff from hot reload config` and the `diff_config` notification of the
webhook - which has the difference with credentials masked.
- **The pages and the answers of the API** carry `X-Frame-Options: DENY` and
`Content-Security-Policy: frame-ancestors 'none'` (no other site can show
them in a frame and turn a click there into one here),
`X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. An
error that is answered by the server's own error page - a request to
`/api/aes` or `/api/config-history` that does not parse - has them not.
- The `--admin` flag takes one account that may write, and the other three
kinds as parameters after the address (see above).
- **History.** With `enable_history=true` on the configuration (see
[pingap-config](../../pingap-config/README.md)) the page of an entry shows
its earlier versions, the newest first with the time each was replaced, and
a button that saves the entry as it was then. That works for every layout
of a file configuration and for etcd. Up to twenty versions of an entry are
shown; one that is the same as the version after it is left out.
What is not there: a content security policy for the pages themselves beyond
the one above.
## Without credentials
`--admin=127.0.0.1:3018`, with no user and password, is a common way to run the
admin on a machine of one's own. The API then answers whatever reaches it, and
a browser on that machine reaches it for every page its user has open. Two
kinds of request are refused with `403`, and a warning at startup says that the
admin has no credentials:
- **A write from another site.** A page elsewhere can send a `POST` it is not
allowed to read the answer of (a form, or `fetch` in `no-cors` mode), which
is all that storing a configuration or asking for a restart takes. A request
other than `GET`/`HEAD` is refused when the browser marks it as coming from
another origin: `Sec-Fetch-Site` is anything but `same-origin` or `none`, or,
where the browser does not send that header (plain http to anything but
localhost), its `Origin` is not the `Host` the request was sent to. A client
that sends neither header, such as `curl`, is not a browser and is served.
- **A name that is not this machine's.** A page under a name that its owner
then points at `127.0.0.1` (DNS rebinding) is, to the browser, the admin's
own page, and could read the configuration with its private keys. On a
connection that came in on a loopback address the `Host` has to be an IP
address, `localhost`, or a name under `.localhost`; any other name is
refused for the whole API. The files of the UI are still served.
Neither applies once credentials are set: the token every request needs is a
header a page of another site cannot add, and a secret a rebound page does not
have. That is also the way out when a credential-less admin on loopback really
is reached under a name (a reverse proxy on the same machine that passes the
`Host` on, an entry in `/etc/hosts`): set a user and a password.
What this does not cover is an admin without credentials on an address of the
network. It is reached by whatever name that network gives it, so the name
cannot be checked, and anyone who can connect can use it. That includes an
admin in a container whose port is published on the host's `127.0.0.1`: inside
the container the connection arrives on the container's own address, not on
loopback, so the name check does not apply there. Set credentials.
## API
All routes are relative to `<path>/api`.
A request body is read up to 8 MiB; a larger one is answered `413`.
| `GET` | `/configs/{category}` | Read configuration for a category |
| `POST` | `/configs/{category}/{name}` | Create or update one entry |
| `POST` | `/configs/import` | Import a whole configuration |
| `DELETE` | `/configs/{category}/{name}` | Delete one entry |
| `GET` | `/config-history/{category}/{name}` | Previous versions, when the storage backend supports history |
| `GET` | `/basic` | Process info, enabled features (includes the TLS backend name `openssl` / `rustls`), supported plugins, upstream health, and `restart_unsupported`: why this process cannot be restarted from here (`container`, `foreground`, `platform`), empty where it can |
| `GET` | `/certificates` | The domains, issuer, validity and ACME source of the loaded certificates. Not the certificates themselves, nor their keys: both used to be in the response, the key read from its file where the configuration only names one. |
| `POST` | `/aes` | AES encrypt/decrypt helper used by the UI for secrets |
| `POST` | `/restart` | Trigger a graceful restart. Answered `400` with the reason where none can be made: pingap in the foreground or as the first process of a container, anywhere but on Linux, a new listen address that cannot be bound, a replacement that does not come up |
`/basic` describes the process: its `user`, `group` and `config_hash` are
those of the configuration it is running, not of what is stored, and the UI,
which asks for it every few seconds, reads nothing from the storage. A control
panel node runs no configuration: there `user` and `group` are read from the
storage on each request, and `config_hash` is that of an empty configuration.
`{category}` is one of `basic`, `server`, `location`, `upstream`, `plugin`,
`certificate`, `storage`. A `POST` to any other category is answered `400`
(`pingap`, the name the UI posts the basic config under, is accepted as
`basic`).
### What a write is checked against
A `POST` is refused with `400` when the configuration it would leave in the
storage is one that `pingap -t` rejects. That is more than the entry on its
own: the references between entries (a location's upstream and plugins, a
server's locations), and whatever only building an entry finds — a path or
host regex that does not compile, a plugin option of the wrong type, a private
key that is not the certificate's. `POST /configs/import` is checked the same
way and has to be a valid configuration as a whole; an import that is empty, or
has a top level section pingap does not know (`[upstream.x]` for
`[upstreams.x]`), is refused, since it would replace what is stored with
nothing.
The check builds upstreams, locations, plugins and certificates the way a
start does, but nothing of it reaches the running proxy: in particular a
`cache` plugin is checked without creating its directory or touching the cache
backends in use. It runs off the worker threads, since it may resolve host
names and read files.
Two things follow from "the configuration it would leave":
- An entry has to exist before another one refers to it: create the upstream,
then the location that names it.
- When the stored configuration is already invalid (edited by hand, or written
by an older version), changes are accepted as long as the entry itself
parses, so it can be repaired through the admin one entry at a time. The
full check applies again from the moment the configuration is valid.
A `DELETE` is refused while another stored entry refers to the one being
removed: an upstream named by a location or a `traffic_splitting` plugin, a
location listed by a server, a plugin listed by a location, a storage named in
an `includes`. The check reads the storage, not the configuration the process
is running, so it holds on a control-panel node and on a node started without
`--autoreload` as well.
```bash
TS=$(date +%s)
```
## Control-panel mode
`pingap --cp --admin=user:pass@127.0.0.1:3018` runs only the admin node: it
manages configuration in the shared backend (typically etcd) without proxying
traffic itself. Data-plane instances watch the same backend and hot reload.
A control-panel node checks a write against the references between entries and
the location patterns only. Upstreams, plugins and certificates are not built
there, and which plugin categories its own build has is not held against the
configuration: building reads files (`ca`, a certificate given as a path) that
belong to the machines the configuration is for. When the stored configuration
names files that are not on the control-panel node at all, it does not pass
there as a whole, and writes are then only checked entry by entry.
One setting of what it stores does apply to the control-panel node itself:
`basic.trusted_proxies`. Failed logins are counted by the address of the
client, and behind an ingress that is the address of the ingress unless it is
named there: the failed logins of anyone then count against everyone, and ten
of them in five minutes lock every administrator out. The node reads the
setting when it starts, every minute after that, and when the basic settings
are changed through it.
It is the list the data plane goes by, and it means the same here as there:
whoever connects from one of its addresses is believed about the client it
speaks for. A client inside a range that is listed - a pod in a subnet named
as a whole - that reaches the admin port directly can so choose the address
its failed logins are counted under, a new one for every guess or that of an
administrator it wants locked out. List the addresses of the proxies, not the
networks they are in, and do not let anything else in those ranges reach the
control-panel node.
## Usage notes
- **An empty `authorizations` disables authentication.** Never expose such an
instance beyond localhost; see [Without credentials](#without-credentials)
for what is and is not refused there.
- The API can change certificates, upstreams and servers and can restart the
process. Bind it to a private interface, or put
[`ip_restriction`](ip_restriction.md) in front of the admin location.
- Configuration written through the API goes to whatever backend `-c` points at.
With `file://` storage, a proxy started with `--upstream` (the config-file-less
quick start) has no editable backing store and the UI cannot be used to change
it.
- The token embeds a timestamp but not the request; it is a bearer credential.
Serve the admin UI over TLS.
- `/configs/{category}/{name}` and `/config-history/{category}/{name}` both
require the name segment; omitting it returns an error rather than a result.