Files
DenozordecandClaude Opus 5.5 ba3cdd08d2
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s
UI polish across sections: one server selector, skeletons, inline errors
- Every section puts the hub's server selector in the chrome slot, the
  same place and gap as the Dashboard.
- Data regions load with a Skeleton and fail inline with Retry; KPIs show
  the no-data dash instead of 0 when a server did not answer. Settings
  keeps its header and selector while loading or failing.
- Cache, Apps and Logs headers group secondary or hub-refused actions in
  menus; app descriptions clamp to two lines; Unknown cells say why.
- DNS Client: Protocol label, Resolve and Import together. DHCP and Query
  Logs empty states with actions. View Logs list capped with scroll.
- Blocking: Lists KPI tiles like the Overview, app list table fits its
  panel with one-line URLs. Status re-reads on tab return at most once
  a minute.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:42:28 +07:00
..

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.
  • Reads go with each server's read token. Writes use a separate admin token: the app changes below (role with apps.write) and the per-server changes of "Managing one server" (roles with operate or admin). 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 needs a read-only API token (View, and nothing else, on Dashboard, Logs, Apps, Settings, Zones, Cache, Allowed, Blocked, DnsClient and DhcpServer) and, for writes, an admin token whose group has Modify and Delete only where the table in docs/hub-deploy.md grants them.

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_DRIFT_MINUTES 15 How often the hub checks that every server still has the config it should (see Drift). 0 turns the check off.
HUB_WEBHOOK_URL none An http or https address that gets a signed JSON notice on a change of drift and on a failed write (see Webhook notices). Treat it like a secret: it may carry a token.
HUB_WEBHOOK_SECRET none Signs each notice: X-Hub-Signature: sha256=<HMAC-SHA256 of the body>. Never logged or returned.
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). 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_LISTS_ALLOW_PRIVATE off 1 lets the hub download the Advanced Blocking block lists (to count their domains, hub/blocking/app-lists/count) from private addresses. Off, only public addresses are fetched, so a hub cannot be pointed at your network. Counts are cached for 6 hours.
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):

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

apps.write is what the app writes below need; operate and admin are what the per-server and several-server changes need. 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.

Changing several servers at once

POST hub/servers/run (write-all, role with operate) runs one fixed action on the chosen servers, in parallel, each with that server's admin token. Parameters: action, targets (comma separated server ids) and the action's own domain, minutes or urls (lines in the grammar of the block lists, up to 300: <url>, !<url> for an allow list, #... for a switched-off list or a heading; http, https or file URLs; a line with a comma is refused). The answer is {results: [{server, ok, message}]}, one per server; a server with no admin token or one that does not answer fails on its own and the others still run. Empty targets or an unknown server are refused with 400, and so is a bad parameter, before any server is called.

action Technitium call parameters sent
blocked.add, blocked.delete blocked/add, blocked/delete domain
allowed.add, allowed.delete allowed/add, allowed/delete domain
blocking.disable settings/temporaryDisableBlocking minutes (1 to 1440)
blocking.enable settings/set enableBlocking=true only
blocking.updateLists settings/forceUpdateBlockLists none
blocking.setListUrls settings/set blockListUrls only (comma joined)
cache.flush cache/flush none
cache.delete cache/delete domain

The block list URLs are part of blocking, so blocking.setListUrls needs operate like the rest (the per-server settings/set of any other field still needs admin). settings/set with enableBlocking as its only field is operate too, so an operator can use the blocking switch on one server. The audit log gets one line per server with the action, the server, the domain or minutes=N or urls=N and the outcome, never the URL list. On the servers, the hub-admin needs Blocked and Allowed Modify and Delete, Cache Delete, and Settings Modify (list URLs, blocking switch).

Managing one server

The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a time. routes.ts lists exactly the paths the console calls; an operate role (operator, admin) may change the cache, Blocked and Allowed, the temporary blocking switch and the list update; zones (records, DNSSEC, import), settings and DHCP need admin. viewer and apps-admin only read.

  • A write needs node=<id> of a server with an admin token. No node, cluster or an unknown id answers 400 ("Choose a server: this change is made on one server at a time."); a server without an admin token answers 403.
  • The parameters travel to the server in a form body (only token stays in the query). A zone import goes on as multipart/form-data or text/plain (up to 20 MB); settings/set, record and scope writes take up to 2 MB and the Blocked/Allowed imports up to 5 MB, for a signed-in caller only.
  • dnsClient/resolve is a read, unless import is set: then it writes into a zone and needs admin.
  • settings/set is refused when it changes a webService* field (compared with the server's current settings/get; the console sends the block unchanged with every save) or carries a user, SSO or LDAP field. settings/get blanks the TSIG shared secrets and the passwords for every role but admin.
  • Reads that have no merger (zones/list, cache/list, allowed|blocked/list, dhcp/*, logs/list, dnsClient/resolve, the exports) are refused without a node instead of being answered by the first server. settings/get is the exception: the header's blocking mark reads it without a node.
  • Downloads (zones/export, logs/download, logs/export, allowed|blocked/export) are passed through (type, file name, body) with the read token. A new tab has no Authorization header, so user/createSingleUseToken gives a token that works once, for 60 seconds, for these paths only.
  • Refused, even for admin: admin/*, user/* (except the hub's own sign-in), logs/delete, logs/deleteAll, dashboard/stats/deleteAll, settings/backup, settings/restore and zones/permissions/*.
  • The audit line holds the user, role, address, path (dots for slashes), server, one identifier (zone, domain, name or scope) and the outcome. Never a value.

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 <ID>_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=<id>: 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.
  • apps/config/get (read, node required) and apps/config/set (apps.write, node required; the old config is saved to the history first and the call is refused if that fails): the console's own per-server Config dialog.

Config sync

Keeps one app's config the same on every server, except for the fields that must differ. Per app the hub stores, under ${HUB_DATA}/apps/<app>/:

  • baseline.json: { app, text, importedFrom?, updatedAt, updatedBy }. The config as TEXT, so non-JSON configs work too.
  • overrides.json: { paths: [...], values: { <server>: { <path>: <value> } } }. Paths are dotted (db.path, networks.0.cidr); at most 50; only for a JSON baseline.
  • history/<time>__<server>__before-apply.txt (and before-uninstall): the config a server had before the hub changed it (50 files per app kept).

A server's effective config is the baseline with that server's local values put in (JSON, pretty-printed with 2 spaces). A local path with no value for a server keeps the baseline value and is reported as a warning. Comparisons are by meaning (key order and spacing do not count) and leave the local fields out, so two servers that differ only there are "Same" in the matrix, and the matrix hash ignores them too.

Endpoint Method Needs Does
hub/apps/config/compare?name= GET read Each server's live config, the baseline, the local fields, inSync, and the differences against the baseline and against the other servers. Config texts are returned here, to roles that can read.
hub/apps/overrides/current POST {name, paths} apps.write The servers' current values of those paths.
hub/apps/baseline/import POST {name, server} apps.write Baseline := that server's live config.
hub/apps/baseline/save POST {name, text} apps.write Saves the baseline (1 MB at most). Text that starts with { or [ must be valid JSON; the parse error is returned.
hub/apps/overrides/save POST {name, paths, values} apps.write Saves the local fields.
hub/apps/config/preview POST {name, targets} apps.write Per target: current and effective text, the diff, warnings (a missing local value, a version older than the newest installed, no admin token) and currentHash.
hub/apps/config/apply POST {name, targets, expect?} apps.write Per target: re-reads the config; refuses if its hash differs from expect[server]; saves the old config to the history; apps/config/set with the admin token; reads it back and checks it. A server that rejects the config keeps its old one.

Zones, settings and blocking across the servers (stage 7)

Endpoint Kind Needs Does
hub/zones/matrix read read Every zone of every server, one row per name: per server present, absent or unknown with type, SOA serial, disabled, DNSSEC and last modified. Row status: same, serial-differs (Primary zones, different serials), type-differs, only-on (onlyOn), missing-on, unknown. A Primary with a Secondary of it is the intended setup: it stays same with note: primary-secondary, or secondary-behind when the Secondary lags. A server that did not answer gives unknown cells and a hubWarnings line; a zone is never called only-on while a server that might have it is silent. Pages of 500, internal zones left out.
hub/zones/copy/preview read admin {source, zone, targets}: the source zone (type, serial, about N record lines) and per target whether it exists (type, serial, needsOverwrite).
hub/zones/copy write-all admin {source, zone, targets, overwrite}. Reads zones/export from the source with the read token (cap 5 MB, 30 s), then per target zones/create (the source's type; a Forwarder with initializeForwarder=false) when the zone is missing and zones/import (overwrite, overwriteZone, overwriteSoaSerial all true, the text as text/plain: what the console's Import zone sends). A target that has the zone is touched only if its id is in overwrite (checked in the hub). Only Primary and Forwarder zones; Secondary, Stub, Catalog and DNSSEC-signed zones are refused with a reason; a target with the zone as another type is refused; a copy to the source is refused. If the import fails after the create, the result says an empty zone was left.
hub/zones/secondary/preview read admin {source, target, zone, primaryAddress, targetAddress, tsigKeyName?}: the two steps in words.
hub/zones/secondary write-all admin Step 1 on the source: zones/options/set with only zoneTransfer, zoneTransferNetworkACL, zoneTransferTsigKeyNames, notify, notifyNameServers; what is listed stays, the target address is appended once (so it can run again), a mode that cannot hold a list is widened (Deny to UseSpecifiedNetworkACL, AllowOnlyZoneNameServers to AllowZoneNameServersAndUseSpecifiedNetworkACL, notify None to SpecifiedNameServers, ZoneNameServers to BothZoneAndSpecifiedNameServers). Step 2 on the target: zones/create type Secondary with primaryNameServerAddresses, zoneTransferProtocol=Tcp, tsigKeyName. The source goes first; if the target step fails the source change is kept and the result says so. The TSIG key must exist on both servers (settings/getTsigKeyNames). Results are two rows, one per server.
hub/settings/compare read admin Per server same, differs (N fields) or local-only, and the differing fields with each server's value (secrets as <secret>, a row only says secretChanged). Without a baseline the servers are compared with the first.
hub/settings/baseline/import write-all admin {server, group?}: the server's settings (read with its admin token) become the baseline; group=blocking replaces only the blocking fields. The old baseline goes to the history.
hub/settings/baseline/save write-all admin {text}: the baseline from the editor (JSON, up to 2 MB). A secret shown as <secret> keeps the stored one; a placeholder with nothing to keep is refused.
hub/settings/overrides/save write-all admin {paths}: extra local field names (up to 50).
hub/settings/preview read admin {targets, group?}: per target the fields an apply would change, as {kind:'json', changes}, secretFields, currentHash.
hub/settings/apply write-all admin {targets, expect?, group?}. Per server: read now, refuse if it changed since the preview, send ONLY the shared fields that differ in one settings/set (through the checks of guards.ts: never webService*, never user, SSO or LDAP fields), after saving the server's settings to the history; read back and compare the fields sent. enableBlocking is left alone while blocking is paused.
hub/settings/history/list, hub/settings/history/get read admin The settings kept before an apply, the lists kept before extras were removed, and old baselines. View only, secrets masked: there is no rollback for settings (put values back by accepting a server as baseline, or by hand).
hub/blocking/lists/compare read admin The custom Blocked and Allowed lists (blocked/export, allowed/export, read as text with a size cap) as sets: per server missing N, extra M with samples.
hub/blocking/lists/baseline/import write-all admin {server}: both lists of that server become the lists baseline (up to 5000 domains each).
hub/blocking/lists/preview, hub/blocking/lists/apply read, write-all admin {targets, removeExtras}. Adds the missing domains, one add call each (at most 1000); deletes the extras only when removeExtras=true (at most 500, after saving the server's lists to the history); reads back.

What is local. Never sent by an apply, listed for information: dnsServerDomain, dnsServerLocalEndPoints, dnsServerIPv4SourceAddresses, dnsServerIPv6SourceAddresses (the host's identity and bind addresses), every webService* (how the hub reaches the server), dnsTlsCertificatePath, dnsTlsCertificatePassword (certificate), dnsOverHttpUnixSocket, dnsOverHttpsUnixSocket, enableDnsOverHttpUnixSocket, enableDnsOverHttpsUnixSocket, dnsReverseProxyNetworkACL (this host's sockets and proxy) and logFolder (a path). User, SSO and LDAP fields are always local. More can be added in the Local fields tab (${HUB_DATA}/settings/overrides.json); the built-in ones cannot be removed. Never compared: version, uptimestamp, clusterInitialized, clusterNodes, temporaryDisableBlockingTill, blockListNextUpdatedOn. Fields only one side reports are skipped.

Secrets. TSIG shared secrets and the proxy password are compared inside the hub by their real value and are stored in the baseline and the history (mode 0600, protect the data folder like the users file). No answer carries a secret or a hash of one, to any role; the endpoints are admin only anyway. A changed secret is shown as "secret changed". The audit log has field names, counts and outcomes, never values. Applying a baseline with TSIG keys does send the secrets to the servers (that is the point), over the connection you configured for them. Files: ${HUB_DATA}/settings/{baseline,overrides}.json, settings/history/ (50 files), ${HUB_DATA}/blocking/baseline.json. Settings and the lists are part of the drift check (see Drift).

History, rollback, drift, export

History. Everything the hub replaces is kept in ${HUB_DATA}/apps/<app>/history/ (50 files per app): <time>__<server>__before-apply.txt, before-set (a single-server config save), before-uninstall, and <time>__baseline__before-save.txt for the baseline's earlier text on every baseline save, import or restore.

Endpoint Method Needs Does
hub/apps/history/list?name= GET read Newest first: { id, at, server, kind, size, hash }, never the text.
hub/apps/history/get?name=&id= GET read The text. id has to be a name the listing returns, so nothing outside the folder can be read.
hub/apps/history/diff?name=&id=&server= GET read A version against the server's config now (changes, currentHash, unchanged).
hub/apps/history/rollback POST {name, id, targets, expect?} apps.write Puts that version on the targets through the same machinery as apply (old config to the history, apps/config/set with the admin token, read back and verify, a result per target, audit). Not for baseline entries.
hub/apps/baseline/restore POST {name, id} apps.write An old baseline entry becomes the baseline again (the current text goes to the history).

Drift. Every HUB_DRIFT_MINUTES (and once shortly after start) the hub reads each server's config of every app that has a baseline, with the read token, and compares it by meaning with the server's effective config (baseline plus its local values). The result is kept in memory and in ${HUB_DATA}/drift.json: { checkedAt, apps: [{ kind, name?, server, since, status, fields? }] }, where kind is app, settings or lists (stage 8), status is differs, unreadable (the server or the read failed; listed, not an alarm) or not-installed, and since is the first check that saw it. Two checks never run at once. See "Webhook notices and profiles" for the settings and lists kinds.

  • GET hub/drift/get (read): the state. POST hub/drift/check (apps.write): check now.
  • user/session/get carries info.hubDrift, the number of differs entries of every kind; the console shows it in the header.
  • POST hub/apps/drift/accept {name, server} (apps.write): "Accept as baseline". The server's config becomes the baseline; if the app has local fields, they keep the baseline's old values and the server's own values go to the overrides instead.
  • "Revert to baseline" is the normal apply on that server.

Export and import. GET hub/apps/export (apps.write, because configs can hold secrets) downloads { hub: 'technitium-hub', version: 1, exportedAt, apps: [{ app, baseline, overrides }] }. POST hub/apps/import {data, apply} (apps.write, up to 5 MB, signed-in callers only) validates the file and answers per app new, changed or same; with apply=true it writes the baselines and overrides (the replaced baselines go to the history). Local values for servers this hub does not have are ignored and reported. It never writes to a server.

Reading configs needs View on Apps for the hub-reader user (and *_ADMIN_TOKEN is used as a fallback if a server refuses the read token). The audit log records who did what, with the size and a short hash of a config, never the text.

Before an uninstall the app's config is saved to ${HUB_DATA}/apps/<app>/history/<time>__<server>__before-uninstall.txt (50 files per app are kept). If that cannot be done the uninstall does not happen. Every target's result is written to the audit log as apps.<action>; the history files hold configs, so protect the data folder like the users file. After any write the hub forgets its cached answers of that server.

Webhook notices and profiles (stage 8)

Webhook. With HUB_WEBHOOK_URL (http or https) the hub POSTs a small JSON body when something changes. HUB_WEBHOOK_SECRET is optional and signs the body. Events:

Event When
drift.found A server/kind/name newly differs from its baseline. Sent once, not at every check.
drift.cleared An entry that differed is back in line. An entry that became unreadable is not cleared: it is not known.
write.failed An audited write ended failed (not refused, not a failed login). Failures of the same moment are one notice.
webhook.test The admin pressed "Send test" (POST hub/webhook/test).
{
  "event": "drift.found",
  "at": "2026-11-01T09:15:00.000Z",
  "hub": "hub.example.net",
  "items": [
    { "kind": "settings", "server": "ndns", "since": "2026-11-01T09:15:00.000Z", "fields": ["blockingType", "defaultRecordTtl"] },
    { "kind": "app", "name": "Advanced Blocking", "server": "ndns", "since": "2026-11-01T09:15:00.000Z" }
  ]
}

Items carry names and field names, never a value. A write.failed item has action, server, target, user, at and the audited detail. The first state is the one in drift.json, so a restart does not repeat notices.

With a secret, the header X-Hub-Signature: sha256=<hex> is the HMAC-SHA256 of the exact request body. Verify it before parsing:

import { createHmac, timingSafeEqual } from 'node:crypto'
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
const ok = expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))

Delivery is fire-and-forget: a 5 second timeout, at most one retry (after a second), redirects are not followed, and it never blocks or fails the request that caused it. A failure goes to the server log with the HOST only (never the query string, which may carry a token) and the reason (HTTP 500, ECONNREFUSED, TimeoutError).

Endpoint Method Needs Does
hub/webhook/status GET admin { configured, host, signed, last: { event, at, ok, error? } }. The host and port only, never the address.
hub/webhook/test POST admin Sends a webhook.test notice now and waits for it; answers the same status. Audited as webhook.test.

Drift kinds. GET hub/drift/get answers { checkedAt, apps: [entry] } (the key is kept) with entry = { kind: 'app' | 'settings' | 'lists', name?, app?, server, since, status, fields? }. status is differs, unreadable or not-installed; fields are setting names, or blocked / allowed for the lists; name (and app) are the app. A drift.json from before stage 8, where kind held the status and app the name, still loads. Settings are compared like the Settings compare view (shared fields against the settings baseline), lists as sets against the lists baseline, and only when that baseline exists. The header count is the number of differs entries of every kind.

Profiles. A profile is a named bundle in ${HUB_DATA}/profiles/<name>.json (mode 0600; it holds settings secrets as the settings baseline does, so protect the folder): store apps with the version they had and the hub's baseline config of each at that moment, the shared fields of the settings baseline (host fields never travel), and the Blocked/Allowed lists baseline. Every profile endpoint needs admin; viewer, operator and apps-admin are refused.

Endpoint Method Does
hub/profiles/list GET Names with counts.
hub/profiles/get?name= GET One profile with secrets as <secret> and no app config text (only whether there is one).
hub/profiles/snapshot {name, apps?, settings, lists} POST Makes or replaces a profile from the current baselines. apps is a comma list (absent: every app with a baseline; empty: none). A zip app, or one installed nowhere, is refused.
hub/profiles/delete {name} POST Deletes it.
hub/profiles/export?name= GET { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt, profiles }, with the real secrets: keep the file as a secret.
hub/profiles/import {data, apply} POST Same size limit as the baselines import (5 MB, signed-in callers only). Dry run first (new, changed, same); apply=true writes. A <secret> placeholder or a host field in the file is refused or dropped.
hub/profiles/preview {name, targets, removeExtras} GET/POST Per target: apps to install, already there, or another version; app configs that would change; settings fields that would change; lists missing and extra. Writes nothing.
hub/profiles/apply {name, targets, removeExtras, from} POST Runs the steps on each target.

Apply. The targets go in parallel; the steps of one server go in order, and a failed step stops the later ones for that server:

  1. apps: the missing apps are installed from THAT server's own apps/listStoreApps (the URL never comes from the request; an installed app is not updated, a version difference is only shown).
  2. configs: the profile's config of each app plus that server's own local values, with the old config saved to the history first and a read-back check (the same as the config apply).
  3. settings: the changed shared fields only, through the checks of guards.ts (no webService*, user, SSO or LDAP field), the old settings in the settings history, and a read-back check.
  4. lists: the missing domains are added; extras are removed only with removeExtras.

The answer is { results: [{ server, ok, message, steps: [{ step, status, message }] }] } with status one of ok, failed, skipped (done by an earlier run) or not-run. from is { "<server>": "<step>" }: the step to start at, so "Retry failed" does not repeat what worked. The audit has one profiles.apply line per server and step (names, counts and the outcome, no values).

Run locally

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

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.