check / check (push) Canceled after 0s
README.md is rewritten from scratch so that every sentence reads on the first pass, and is cut from about 5,600 words to about 3,500. It leads with what netwatch is, what it is for and how to run it, then the page, reporting and deployment, then every setting in a reference section, with design and development last. Mechanism detail the code and its comments already hold is left out; the PR lists each topic dropped and why. TODO.md records the rewrite of the three documents. Model: fable-5-1 (rewrite, review); opus-5-5 (merge) Co-authored-by: clawbot <clawbot@noreply.example.org>
458 lines
22 KiB
Markdown
458 lines
22 KiB
Markdown
NetWatch is an MIT-licensed single-page web application by
|
|
[@sneak](https://sneak.berlin) that shows the latency from your browser to 26
|
|
well-known internet hosts. The page is JavaScript and does all its measuring in
|
|
the browser. A small Go backend, `netwatch-server`, stores the measurements the
|
|
page reports. The page and the backend ship together in one Docker image.
|
|
|
|
## Rationale
|
|
|
|
When a network misbehaves, it helps to see at a glance which hosts answer and
|
|
how fast. NetWatch is one page with a latency figure and a small graph for each
|
|
host, refreshed every few seconds. The page measures from the browser, so it
|
|
shows the path your own traffic takes. It can be served from any static file
|
|
host. The backend only stores what the page reports. Without it, the page works
|
|
the same and nothing is stored.
|
|
|
|
## Getting Started
|
|
|
|
```bash
|
|
# Install the dependencies and the git pre-commit hook
|
|
make setup
|
|
|
|
# Serve the page from the Vite dev server
|
|
make dev
|
|
|
|
# Build the page and netwatch-server, then run the server on port 8080
|
|
make run
|
|
|
|
# Run the tests, both linters and the format check
|
|
make check
|
|
|
|
# Build the page into dist/ and netwatch-server into the repo root
|
|
make build
|
|
|
|
# Build the Docker image and run it
|
|
make docker
|
|
docker run -p 8080:8080 netwatch
|
|
```
|
|
|
|
`make check` and `make docker` need Docker. `make run` starts `netwatch-server`
|
|
on port 8080 with its defaults, so the page's reports are stored in
|
|
`data/reports`. `make dev` passes the page's `/api` requests there.
|
|
|
|
## The page
|
|
|
|
The page shows one row per host: the host's name, its URL, its latest latency in
|
|
milliseconds and a graph of its last 100 checks. Below the figure are the
|
|
minimum, median, average and maximum of the host's answers in that history, or
|
|
`timeout` or `unreachable` after a failed check. A round of checks runs every 3
|
|
seconds by default. The header shows the interval, the history's span, and
|
|
whether the page is running or paused.
|
|
|
|
### Targets
|
|
|
|
The page checks 26 hosts on the internet, the WAN hosts:
|
|
|
|
- `datavi.be`, pinned to the top at start
|
|
- the Anthropic API and the OpenAI API
|
|
- the AWS Console, the Google Cloud Console and Microsoft Azure
|
|
- Cloudflare, Fastly, Akamai, Google, GitHub and Backblaze B2
|
|
- 8 S3 regional endpoints: Cape Town, London, Bahrain, Tokyo, Singapore, Sydney,
|
|
Oregon and São Paulo
|
|
- 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn,
|
|
Hillsboro and Singapore
|
|
|
|
Two local targets follow under the heading "Local Network", left out of the
|
|
summary and the health box. `Local CPE` is the cable modem at
|
|
`http://192.168.100.1`. `Local Gateway` is the first of `http://192.168.1.1`,
|
|
`http://192.168.0.1`, `http://192.168.8.1` and `http://10.0.0.1` to answer
|
|
within 1.5 seconds of the page loading; with no answer, it has no row. Browsers
|
|
block a page served from a public address from reaching private addresses. So
|
|
the local targets answer only when the page is served from `localhost` or from a
|
|
private address.
|
|
|
|
The host list is written in `web/src/main.js`; there is no setting for it.
|
|
|
|
### What a figure measures
|
|
|
|
A check is a request to the target's URL: `GET`, or `HEAD` for the S3 regions.
|
|
The figure is the time from sending the request until the response headers
|
|
arrive, in milliseconds. Unlike ping, it includes the time the server takes to
|
|
answer. On a new connection it also includes the DNS lookup, the connection
|
|
setup and the TLS handshake. Any answer counts as reachable, an error page
|
|
included. The browser follows redirects, so a target whose home page redirects
|
|
is checked at `/robots.txt` on the same host.
|
|
|
|
A check times out after 80% of the interval: 2.4 seconds at the default. A check
|
|
that answers after that limit counts as a timeout too. A check that fails with a
|
|
network error is recorded as unreachable. The first round after the page loads
|
|
is discarded, as its figures include the connection setup. So is the first round
|
|
after an interval change.
|
|
|
|
### Health and summary
|
|
|
|
Above the rows, a health box sums up the WAN hosts' latest results. A host
|
|
counts as failed when its last check timed out or found it unreachable.
|
|
|
|
- `OFFLINE` (red): more than 10 hosts failed and at most 4 answered
|
|
- `DEGRADED` (orange): otherwise, more than 4 hosts failed
|
|
- `SLOW` (yellow): otherwise, more than 3 hosts took over 1000 ms
|
|
- `HEALTHY` (green): otherwise
|
|
|
|
Below it, a summary line covers the WAN hosts only:
|
|
|
|
- `Reachable`: how many of the 26 answered their last check
|
|
- `Now`: the minimum, median, average and maximum of their latest figures
|
|
- the history's span, such as `5m`: the minimum and maximum over the whole
|
|
history
|
|
- `Checks`: the rounds run since the page loaded or the interval changed
|
|
|
|
When no WAN host answers, the page checks 4 of them, picked at random, every
|
|
half second. As soon as one answers, a new round starts.
|
|
|
|
### Rows, colors and controls
|
|
|
|
A figure and its line in the graph take the color of the legend in the footer,
|
|
from green under 50 ms to red from 500 ms up; gray is no answer. The graph's
|
|
axis ends at 1000 ms; a higher latency is drawn at the top, and the figure still
|
|
shows its real value.
|
|
|
|
From time to time, the page sorts the WAN rows by their latest latency: fastest
|
|
first, failed last. The pin button at the left of a row keeps the row at the
|
|
top; pinned rows are sorted by name. `datavi.be` is pinned at start.
|
|
|
|
`Pause` stops the checks: the graphs keep scrolling with a gap for each paused
|
|
round, and the figures turn gray. The `Interval` menu sets the time between
|
|
rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the history and
|
|
starts a new round at once. The `Debug log` checkbox in the footer opens a panel
|
|
with the page's last 1000 log lines.
|
|
|
|
## Reporting
|
|
|
|
Every 60 seconds the page posts its undelivered samples as a report to
|
|
`/api/v1/reports` on the origin it was served from. The report is JSON with four
|
|
fields:
|
|
|
|
- `clientId`: a random id, made once per browser and kept in `localStorage`, or
|
|
once per session when the browser has no usable storage
|
|
- `geo`: always `null`
|
|
- `hosts`: one entry per host with new samples: its `name`, `url` and `status`,
|
|
and `history`, its samples. A sample has the check's time `t` in Unix
|
|
milliseconds, its `latency` (`null` for a failed check) and its `error`
|
|
(`"timeout"`, `"unreachable"` or `null`).
|
|
- `timestamp`: the time of the report
|
|
|
|
Each report carries only the samples no earlier report has delivered. A sample
|
|
waits as long as the history holds it: the last 100 rounds of each host. An
|
|
interval change clears the history. The cleared samples are kept, the last 100
|
|
of each host, until a report delivers them. Nothing is sent while the page is
|
|
paused. A report with no answer after 30 seconds is abandoned, and its samples
|
|
go in the next one. So a server that stored them but answered late gets them
|
|
twice.
|
|
|
|
A failed delivery is quiet, as on a static file host with no backend: one debug
|
|
log line when deliveries start failing, one when they recover. The checks go on.
|
|
|
|
## Deployment
|
|
|
|
`make docker` builds the image `netwatch` from `Dockerfile`: the whole service
|
|
in one container. nginx serves the page and passes `/api/`,
|
|
`/.well-known/healthcheck` and `/metrics` to `netwatch-server` in the same
|
|
container. The backend listens on `127.0.0.1:8081` only, as user `netwatch` (uid
|
|
1000). `bin/entrypoint.sh` starts both and watches them.
|
|
|
|
Before the backend starts, `bin/entrypoint.sh` runs two subcommands of
|
|
`netwatch-server`. `check-cidr` checks each `TRUSTED_PROXIES` entry (see
|
|
[Running under upaas](#running-under-upaas)). `prepare-data-dir` creates the
|
|
report directory and gives `/data`, with the directories, regular files and
|
|
symbolic links in it, to user `netwatch`. It changes nothing outside `/data`.
|
|
|
|
The image also:
|
|
|
|
- takes the client address from `X-Forwarded-For` only on a request from a proxy
|
|
named in `TRUSTED_PROXIES`, and passes that one address to the backend. The
|
|
backend is started with `TRUSTED_PROXIES=127.0.0.1/32`, so it trusts nginx
|
|
alone.
|
|
- writes nginx's access log and the backend's JSON log to stdout
|
|
- refuses a request body over 1 MiB with nginx's own 413 error page
|
|
- requests `/.well-known/healthcheck` through nginx every 30 seconds, as its
|
|
`HEALTHCHECK`. The check fails unless both nginx and the backend answer, and
|
|
while report files cannot be written.
|
|
- on `docker stop`, writes the waiting reports to disk and exits 0, or non-zero
|
|
if that write fails. If nginx or the backend exits on its own, the other is
|
|
stopped and the container exits non-zero.
|
|
|
|
### Running under upaas
|
|
|
|
The [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
|
|
|
|
- Port: container port `8080`.
|
|
- Volume: container path `/data`.
|
|
- Environment variables: none is required. Every setting in
|
|
[Settings](#settings) applies, with four differences in the image:
|
|
- `BIND_ADDRESS` has no effect. The backend listens on `127.0.0.1` inside
|
|
the container.
|
|
- `PORT` is nginx's public port, from 1 to 65535 and not `8081`, where the
|
|
backend listens inside the container.
|
|
- `DATA_DIR` must be `/data` or a path below it, written in full, with no
|
|
`.` or `..` part and no extra `/`. A path through a symbolic link is
|
|
refused unless the link is relative and stays inside `/data`.
|
|
- `TRUSTED_PROXIES` names the proxies nginx trusts, as IP addresses or CIDRs
|
|
separated by commas; the backend's own setting is fixed to `127.0.0.1/32`.
|
|
`bin/entrypoint.sh` gives a lone address a `/32` or `/128` and checks each
|
|
entry with `netwatch-server check-cidr`, which refuses what the backend
|
|
would refuse. Set and empty, nginx trusts no proxy, and every client
|
|
behind a proxy then shares the proxy's one allowance of
|
|
`REPORTS_PER_MINUTE`. A client that connects from a trusted address can
|
|
write its own `X-Forwarded-For`. A port Docker publishes can bring
|
|
connections from the Docker network's gateway, such as `172.17.0.1`, which
|
|
the default includes. Where that matters, name only the proxy's own
|
|
addresses.
|
|
- Health: the image's `HEALTHCHECK`, described above.
|
|
|
|
The container stops at start when a variable holds a value it cannot use. The
|
|
reason is in its log.
|
|
|
|
## Settings
|
|
|
|
`netwatch-server` reads its settings from environment variables. It also reads a
|
|
`.env` file in the directory it starts in, and a YAML file named
|
|
`netwatch-server.yaml` in `/etc/netwatch-server/` or
|
|
`~/.config/netwatch-server/`, when one exists. A variable in the environment
|
|
wins over a file.
|
|
|
|
The server does not start when a variable holds a value it cannot use. Its error
|
|
names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`.
|
|
|
|
- `BIND_ADDRESS`, default empty: the IP address to listen on. Empty listens on
|
|
every interface. The image sets it to `127.0.0.1`.
|
|
- `PORT`, default `8080`: the port to listen on, from 1 to 65535. In the image
|
|
this is nginx's port; the backend listens on `8081`.
|
|
- `DATA_DIR`, default `./data/reports` (`/data/reports` in the image): the
|
|
directory for the report files. The server creates it if it is missing, and
|
|
stops at start if it cannot create a file in it.
|
|
- `DATA_DIR_MAX_BYTES`, default `1073741824` (1 GiB): the most bytes of report
|
|
files kept in `DATA_DIR`, oldest deleted first. A positive whole number. See
|
|
[Report limits](#report-limits).
|
|
- `REPORTS_PER_MINUTE`, default `60`: how many reports each client address may
|
|
send a minute. A positive whole number. See [Report limits](#report-limits).
|
|
- `TRUSTED_PROXIES`, default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`: the
|
|
reverse proxies whose `X-Forwarded-For` and `X-Real-IP` headers are trusted,
|
|
as CIDRs separated by commas. See [Trusted proxies](#trusted-proxies).
|
|
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call the
|
|
API, separated by commas. See [CORS](#cors).
|
|
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: the basic auth
|
|
credentials for `/metrics`. See [Metrics](#metrics).
|
|
- `SENTRY_DSN`, default empty: the DSN of a Sentry project. Each request whose
|
|
handling crashes is reported there; the request still gets a 500.
|
|
- `DEBUG`, default empty: any non-empty value turns debug logging on, `false`
|
|
and `0` included. Every value is accepted.
|
|
|
|
### Trusted proxies
|
|
|
|
Each `TRUSTED_PROXIES` entry must be a CIDR, such as `127.0.0.1/32`; a lone
|
|
address is refused. A list replaces the default; set and empty, the server
|
|
trusts no proxy. Loopback is not in the default, so a reverse proxy on the same
|
|
host has to be named.
|
|
|
|
A request from a peer outside this set has its forwarded headers ignored; the
|
|
peer's own address is logged and rate limited. From a peer inside the set, the
|
|
client address is the right-most `X-Forwarded-For` entry that is not in the set.
|
|
The peer's address is used when every entry is in the set, or when the search
|
|
from the right meets an entry that is not an address, such as `unknown`.
|
|
`X-Real-IP` is read only when `X-Forwarded-For` is missing or empty.
|
|
|
|
### Routes
|
|
|
|
- `POST /api/v1/reports` takes a report from the page. It needs no credentials;
|
|
[Report limits](#report-limits) bound it instead.
|
|
- `GET /.well-known/healthcheck` answers 200 with a JSON object: `appname`,
|
|
`version`, `now`, `uptime_seconds`, `uptime_human` and `status`, which is
|
|
`"ok"`. While report files cannot be written, it answers 503 with the same
|
|
fields and `"status":"error"`.
|
|
- `GET /metrics` serves Prometheus metrics, only when `METRICS_USERNAME` and
|
|
`METRICS_PASSWORD` are set. See [Metrics](#metrics).
|
|
|
|
Every request body is capped at 1 MiB, and every request at 60 seconds.
|
|
|
|
### Report storage
|
|
|
|
The server keeps the reports it takes in memory and writes them to `DATA_DIR` as
|
|
files of JSON lines, compressed with zstd. It writes a new file every minute,
|
|
whenever 10 MiB of reports are waiting, and when it stops. With none waiting, it
|
|
writes no file. The files are named `reports-<timestamp>-<number>.jsonl.zst`;
|
|
the timestamp is UTC to the millisecond, so the names sort by time.
|
|
|
|
The reports of a failed write, as on a full disk, stay in memory for the next
|
|
write to try again. Until a write succeeds, `POST /api/v1/reports` refuses every
|
|
report with 500 and the health check answers 503.
|
|
|
|
### Report limits
|
|
|
|
`POST /api/v1/reports` takes reports from anyone who can reach it, so it is
|
|
bounded in four ways. Each refusal by the backend answers with the body
|
|
`{"status":"error"}`.
|
|
|
|
- JSON only. A report must be sent with `Content-Type: application/json`; any
|
|
other content type is refused with 415, and malformed JSON with 400.
|
|
- Body limit. A report body may be at most 1 MiB. A request that declares a
|
|
longer body, or a report that runs past that, is refused with 413.
|
|
- Rate limit. Each client address may send `REPORTS_PER_MINUTE` reports a
|
|
minute; past that it gets 429 with `Retry-After: 60`. The page sends one
|
|
report a minute from each open tab, so the default of 60 refuses nothing from
|
|
up to 30 tabs behind one address.
|
|
- Size cap. The report files in `DATA_DIR` may total at most
|
|
`DATA_DIR_MAX_BYTES`, counting the files already there at start and the
|
|
reports waiting in memory. When a report would take the total past the cap,
|
|
the oldest files are deleted to make room. A report is refused with 507 when
|
|
deleting the oldest files cannot make room.
|
|
|
|
At the defaults, one address can fill the 1 GiB cap with its own reports in
|
|
about 17 minutes, and every report stored before them is deleted to make room.
|
|
|
|
### CORS
|
|
|
|
The page needs no entry here: it calls the API from the origin it is served
|
|
from. By default the server sends no CORS headers, and browsers let no other
|
|
origin's pages call it. `CORS_ALLOWED_ORIGINS` lists other origins, such as
|
|
`https://netwatch.example.com`, whose pages may then call the API with `GET` and
|
|
`POST`. Each entry must be a plain origin as browsers send it: `scheme://host`,
|
|
with an optional `:port`, no path, no trailing `/` and no `*`.
|
|
|
|
### Metrics
|
|
|
|
With both `METRICS_USERNAME` and `METRICS_PASSWORD` set, the server serves
|
|
Prometheus metrics at `GET /metrics`. A request with those as its basic auth
|
|
credentials gets them; any other gets 401. With neither set, `/metrics`
|
|
answers 404. The metrics record each request that reaches the health check or
|
|
`POST /api/v1/reports`: its duration and response size, labelled with its path,
|
|
method and status. Each client address may make 60 requests to `/metrics` a
|
|
minute. Past that it gets 429 with `Retry-After: 60`, and its credentials are
|
|
not checked.
|
|
|
|
### Logging
|
|
|
|
The backend logs JSON to stdout, or plain text when stdout is a terminal. Each
|
|
request is logged with its method, URL, status, client address and duration.
|
|
Every value taken from a request is cut to 128 bytes before it is logged.
|
|
|
|
## Design
|
|
|
|
The repository holds the page, the backend and the image:
|
|
|
|
- `web/`: the page's source. `index.html` loads `src/main.js` and
|
|
`src/styles.css`.
|
|
- `cmd/netwatch-server/` and `internal/`: the Go backend. `cmd/` holds only
|
|
`main`, which calls into `internal/cli`.
|
|
- `configs/`: nginx's configuration for the image, `nginx.conf` and
|
|
`security-headers.conf`.
|
|
- `bin/entrypoint.sh`: the image's entrypoint.
|
|
- `Dockerfile`: the lint and test phases, the two build stages and the image.
|
|
- `script/`: the development commands; `Makefile` targets call them.
|
|
- `test/unit/`: the page's unit tests. `test/viewport/`: the layout test.
|
|
|
|
### The page
|
|
|
|
The page is built with Vite and Tailwind CSS v4. All its code is in
|
|
`web/src/main.js`:
|
|
|
|
- `CONFIG`: the timing and display constants. The interval menu sets its
|
|
`updateInterval`; the check timeout and the history span follow from it.
|
|
- `WAN_HOSTS`: the WAN targets, each with its URL and, for the S3 entries, the
|
|
method `HEAD`.
|
|
- `HostState` and `AppState`: one host's history and status, and the whole
|
|
page's state.
|
|
- `measureLatency`: one check. `tick`: one round, which checks every host in
|
|
parallel and draws each row as soon as its check ends.
|
|
- `SparklineRenderer`: the graphs. `buildUI`, `updateHostRow`, `updateSummary`
|
|
and `updateHealthBox`: the page and its parts.
|
|
- `Reporter`: builds and posts the reports. `buildReport` is a pure function of
|
|
the hosts' state.
|
|
|
|
### The backend
|
|
|
|
`netwatch-server` is a Go HTTP server. The `fx` library starts and stops its
|
|
parts in dependency order. The backend's packages are in `internal/`:
|
|
|
|
- `cli`: the command line: the server when given no arguments, or the
|
|
`check-cidr` and `prepare-data-dir` subcommands that `bin/entrypoint.sh` runs.
|
|
- `config`: reads the settings and refuses values the server cannot use.
|
|
- `server`: the router, the routes and the HTTP server.
|
|
- `middleware`: the request log, the security headers, CORS, the body limit, the
|
|
rate limit, the client address resolution and the metrics.
|
|
- `handlers`: the health check and the report route.
|
|
- `reportbuf`: the in-memory buffer of reports and the files it writes them to.
|
|
It also holds `PrepareDataDir`, which `prepare-data-dir` runs.
|
|
- `healthcheck`, `logger` and `globals`: the health status, the logger, and the
|
|
name and version the build stamps in.
|
|
|
|
## Development
|
|
|
|
### Entrypoints
|
|
|
|
This repository adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard. The scripts in `script/` are the entrypoints for the development
|
|
workflow, and the `Makefile` targets are thin shims that call them.
|
|
|
|
- `script/bootstrap`: install the dependencies. It installs the pinned node,
|
|
yarn and Go when the ones on `PATH` do not pass its checks, and links them
|
|
into `~/.local/bin`, which has to be on `PATH`. It installs no Go linter and
|
|
no Docker.
|
|
- `script/setup`: `script/bootstrap`, then the git pre-commit hook.
|
|
- `script/dev`: run the Vite dev server.
|
|
- `script/build`: build the page into `dist/`, then the static `netwatch-server`
|
|
binary into the repo root. Its version is `VERSION` from the environment, else
|
|
`git describe --tags --always --dirty`, else `dev`.
|
|
- `script/run`: `script/build`, then run `netwatch-server` with its defaults.
|
|
- `script/clean`: remove what `script/build` writes.
|
|
- `script/projectname`: print `netwatch`, the image tag `script/docker` uses.
|
|
- `script/yarn`: run yarn under the node `script/bootstrap` would use.
|
|
- `script/test`: build the `test` phase of `Dockerfile`, writing no image: the
|
|
Go tests with the race detector and coverage, and the page's unit tests.
|
|
- `script/lint`: build the `lint` phase of `Dockerfile`, writing no image:
|
|
golangci-lint over the Go packages and eslint over the JavaScript.
|
|
- `script/fmt`: run the formatters: prettier over the JavaScript, CSS, HTML and
|
|
Markdown, then gofmt over `cmd/` and `internal/`. They write the files in
|
|
place.
|
|
- `script/fmt-check`: check the formatting (read-only), with the same scope as
|
|
`script/fmt`.
|
|
- `script/check`: `script/test`, `script/lint` and `script/fmt-check`.
|
|
- `script/add-dependency`: add a page dependency, or move one to another
|
|
version: `make add-dependency PACKAGE=<name>@<version>`.
|
|
- `script/tidy`: run `go mod tidy`.
|
|
- `script/frontend-test`: run the unit tests in `test/unit/` on the host, with
|
|
Node's built-in test runner, then the production build.
|
|
- `script/frontend-fmt` and `script/frontend-fmt-check`: the prettier half of
|
|
`script/fmt` and `script/fmt-check`.
|
|
- `script/frontend-viewport-test`: check the built page's layout in a
|
|
containerised headless Chrome, not as part of `script/check`; see
|
|
[test/viewport/README.md](test/viewport/README.md).
|
|
- `script/docker`: build the image from `Dockerfile`, tagged `netwatch`.
|
|
- `script/cibuild`: `script/bootstrap`, `script/check`, then the image build.
|
|
The Gitea workflow `.gitea/workflows/check.yml` runs it on every push.
|
|
- `script/precommit`: run by the git pre-commit hook; it runs `script/check`.
|
|
- `script/install-precommit`: install the git pre-commit hook. `make hooks`
|
|
calls it.
|
|
|
|
### Tests
|
|
|
|
`make test` runs the backend's Go tests and the page's unit tests in Docker. The
|
|
unit tests import `web/src/main.js` in Node and call the functions it exports;
|
|
no browser is involved. `make frontend-viewport-test` checks the built page's
|
|
layout in a headless Chrome; [test/viewport/README.md](test/viewport/README.md)
|
|
says what it checks and what it cannot.
|
|
|
|
## TODO
|
|
|
|
The to-do list is [TODO.md](TODO.md): where the work stands, the next step, the
|
|
open work, and what has been done.
|
|
|
|
## License
|
|
|
|
MIT. See [LICENSE](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|