Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator, apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock the address or the name for 15 minutes. Logins, logouts and every refused request go to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are listed in routes.ts with their kind and capability; writes stay refused for now. Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan is in docs/hub-management-plan.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
112 lines
5.9 KiB
Markdown
112 lines
5.9 KiB
Markdown
# Technitium hub
|
|
|
|
A small, dependency-free Node program that looks like ONE Technitium DNS Server to the
|
|
console and answers from several real ones. It serves the console's static build and a
|
|
read-only, Technitium-compatible `/api/*`:
|
|
|
|
- `node=<id>` forwards the call to that server, with that server's token.
|
|
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers
|
|
(statistics are summed, top lists and query logs are merged). If a server does not
|
|
answer, the others are shown and the response carries `hubWarnings`.
|
|
- Anything that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
|
|
|
|
The servers themselves are not changed: each only needs a read-only API token. Create it
|
|
for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
|
|
apps Query Logs reads from) and Settings (the blocking state).
|
|
|
|
## Configuration
|
|
|
|
| Variable | Default | Meaning |
|
|
|---|---|---|
|
|
| `HUB_SERVERS` | required | Ordered `id=url` list, e.g. `dns=https://dns.example.net,ndns=https://ndns.example.net`. The first server is shown as the Primary node. |
|
|
| `<ID>_TOKEN` | required | API token of each server; the id is uppercased with non-alphanumerics as `_` (`dns` gives `DNS_TOKEN`, `my-dns` gives `MY_DNS_TOKEN`). |
|
|
| `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. |
|
|
| `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. |
|
|
| `HUB_TRUST_PROXY` | off | `1` believes the first `X-Forwarded-For` entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
|
|
| `<ID>_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Read into the config for the coming write stages; not used yet and never logged. |
|
|
| `HUB_PORT` | `8080` | Listening port. |
|
|
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
|
|
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
|
|
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
|
|
| `HUB_STATIC` | `./dist` | The console's build to serve. |
|
|
|
|
## Users and roles
|
|
|
|
The hub has its own accounts in `${HUB_DATA}/users.json` (`{ "users": [{ "name", "role", "hash", "disabled"? }] }`,
|
|
hash `scrypt$N$r$p$salt$key`). Names are compared case-insensitively.
|
|
|
|
- If the file exists, it is the only source; `HUB_USER` / `HUB_PASSWORD` are ignored (a line says so at start).
|
|
- If it does not exist and `HUB_USER` / `HUB_PASSWORD` are set, that one user signs in as `admin`. The file is not written.
|
|
- If neither exists, the hub refuses to start.
|
|
|
|
Manage the file with the CLI (`npm run hub:user -- <command>` locally):
|
|
|
|
```sh
|
|
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
|
|
```
|
|
|
|
`add <name> <role>`, `passwd <name>`, `role <name> <role>`, `disable <name>`, `enable <name>`, `list`. The password is asked
|
|
twice without echo; for scripts use `HUB_NEW_PASSWORD=... users-cli.ts add ...` (minimum 8 characters). The file is written
|
|
atomically with mode 0600. The running hub notices a change on the next request and ends the sessions of the users it touched
|
|
(disabled, removed, new role or password).
|
|
|
|
| Role | Capabilities |
|
|
|---|---|
|
|
| `viewer` | read |
|
|
| `operator` | read, operate (blocking, cache) |
|
|
| `apps-admin` | read, apps.write |
|
|
| `admin` | everything, including audit.read |
|
|
|
|
Only reads exist today; the write capabilities are the machinery for the next stages. Every response also carries
|
|
`hubRole` (and `info.hubRole`).
|
|
|
|
## Login protection
|
|
|
|
After 5 failed logins within 15 minutes from one client address, or for one user name, further attempts are refused for 15
|
|
minutes with HTTP 429 and `Too many failed logins. Try again in N minutes.` (the console shows that message). A good login
|
|
resets the user's counter. The state is in memory.
|
|
|
|
## Audit log
|
|
|
|
`${HUB_DATA}/audit.log` is JSON Lines: `{ts, user, role, ip, action, server?, target?, outcome, detail?}`. It records login
|
|
(ok, failed, locked), logout, and every refused request outside the permission table as `refused-write` with its api path.
|
|
Passwords, tokens and request bodies are never written. At 10 MB it rotates to `audit.log.1` (one kept). If the folder is not
|
|
writable the hub says so once on stderr and keeps the last 1000 entries in memory. Admins read it with
|
|
`GET /api/hub/audit/list?limit=200&before=<ISO time>`, newest first.
|
|
|
|
## Endpoints
|
|
|
|
Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`,
|
|
`user/checkForUpdate` (always "no update") and these reads:
|
|
|
|
`dashboard/stats/get`, `dashboard/stats/getTop`, `dashboard/metrics/json`, `apps/list`,
|
|
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized), and the hub's own `hub/audit/list` (role with
|
|
`audit.read`). They are listed in `routes.ts` with a kind (`read`, `write-one`, `write-all`) and the capability they need.
|
|
|
|
Everything else answers `403 The hub is read-only.` and is audited.
|
|
|
|
## Run locally
|
|
|
|
```sh
|
|
npm run build
|
|
node dev/mock-api.mjs --port 5380 &
|
|
node dev/mock-api.mjs --port 5381 --scale 2 &
|
|
HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \
|
|
HUB_USER=admin HUB_PASSWORD=admin HUB_DATA=./hub-data HUB_PORT=5390 npm run hub
|
|
```
|
|
|
|
Then open http://localhost:5390. Tests: `npx vitest run hub`.
|
|
|
|
## Docker
|
|
|
|
```sh
|
|
docker run --rm -p 8080:8080 \
|
|
-e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \
|
|
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
|
|
git.shx.one/denozord/technitium-hub:latest
|
|
```
|
|
|
|
The image is built by `docker/Dockerfile.hub` and published by `.gitea/workflows/hub.yaml`
|
|
(`latest` from `main`, `sha-<7>`, and `X.Y.Z` / `X.Y` from a `hub-vX.Y.Z` tag). A compose
|
|
example with Traefik is in `docs/compose.hub.example.yaml`.
|