# 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=` 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`. - Reads go with each server's read token. The only writes are the app changes below, which use a separate admin token and a role with `apps.write`. Anything else 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. | | `_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. | | `_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. | | `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 -- ` locally): ```sh docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin ``` `add `, `passwd `, `role `, `disable `, `enable `, `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 | `apps.write` is what the app writes below need; `operate` and the rest are still unused. 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=`, 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. `apps/config/get` and `apps/config/set` are not available yet. ## App writes Needs the `apps.write` role (`apps-admin`, `admin`); others get `403 Your role does not allow this.` and a `denied-write` audit line. Every write goes to a server with its `_ADMIN_TOKEN`; a server without one answers "no admin token configured" and is not called. - `POST hub/apps/run` (form or JSON): `action` = `install-store` | `update-store` | `uninstall`, `name`, `targets` (list or comma-separated server ids). Runs on every target in parallel and answers `{ results: [{ server, ok, message? }] }`. The download URL is taken from each target's own `apps/listStoreApps`; a `url` field is only accepted when it matches. - `POST hub/apps/upload` (multipart, at most 20 MB, kept in memory only): `action` = `install` | `update`, `name`, `targets` (comma-separated), file field `fileAppZip`. The same zip is forwarded to every target. - `apps/downloadAndInstall`, `apps/downloadAndUpdate`, `apps/uninstall`, `apps/install`, `apps/update` with `node=`: the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and with `node=cluster`. - `apps/listStoreApps`: the first server's store, each app tagged with `installedOn`. Before an uninstall the app's config is saved to `${HUB_DATA}/apps//history/