Compare commits

...
7 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 042cb116ea Hub docs: the two servers are mdns.shnt.top and ndns.shnt.top, the hub is on dnss.shnt.top
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 41s
CI / image (push) Successful in 2m11s
Hub / image (push) Successful in 1m48s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:53:53 +07:00
DenozordecandClaude Opus 5.5 607d46f872 Compose example: the hub on dnss.shnt.top beside the server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:50:50 +07:00
DenozordecandClaude Opus 5.5 a86d1a088a Hub: step-by-step deployment guide; Dockerfile comment the language check flagged
docs/hub-deploy.md walks through the image, a read-only hub-reader user and token on
each server, the DNS record, .env, the compose service, checks, updates and
troubleshooting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:38:56 +07:00
DenozordecandClaude Opus 5.5 3f917c6bd2 Hub stage 4: its own image and pipeline
docker/Dockerfile.hub builds the console and ships it with the hub sources on
node:22-alpine, without node_modules, as a non-root user with a healthcheck.
.gitea/workflows/hub.yaml checks and pushes git.shx.one/denozord/technitium-hub
(latest from main, sha-*, X.Y.Z and X.Y from hub-v tags), independent of the console
pipeline, which now ignores hub-only changes. Compose example for hub.shnt.top and
hub/README.md, with the read-only permissions each server's token needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:21:59 +07:00
DenozordecandClaude Opus 5.5 48b5d9fb15 Hub stage 3: the console in hub mode
With info.hub the node selector reads Server / All servers, the default-credentials
password prompt and the account actions the hub cannot serve are gone, the header
status reports the servers an aggregate could not reach (hubWarnings, kept until the
next aggregate), and merged Query Logs show a Server column. Without the flag
nothing changes. The hub sends hubWarnings on every aggregate answer, empty when all
servers answered, and never on a single-node one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:18:58 +07:00
DenozordecandClaude Opus 5.5 5f29a438c9 Hub stage 2: read-only Technitium-compatible server over several servers
node:http server that serves the console build and a /api/* the console accepts:
its own login and session (clusterInitialized, the servers as nodes, hub flag,
Dashboard and Logs only), node=<id> forwarded with that server's token, no node or
cluster merged (stats, top lists, query logs, apps), every write refused with 403,
a short response cache, and an unreachable server reported as hubWarnings. The dev
mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:04:41 +07:00
DenozordecandClaude Opus 5.5 9df2c2b908 Hub stage 1: pure merge functions for stats, top lists and query logs
Sums dashboard counters, aligns chart series by label, merges top lists by name and
query logs by time (page N cut from the merged head of every server), and reports an
unreachable server as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:51:37 +07:00
29 changed files with 2462 additions and 28 deletions

No files matched your search

+7
View File
@@ -4,6 +4,13 @@ on:
push:
branches: [main]
tags: ['v*']
# The hub has its own pipeline (hub.yaml): a change that only touches it does
# not rebuild the console.
paths-ignore:
- 'hub/**'
- 'docker/Dockerfile.hub'
- '.gitea/workflows/hub.yaml'
- 'docs/compose.hub.example.yaml'
pull_request:
branches: [main]
workflow_dispatch:
+81
View File
@@ -0,0 +1,81 @@
# The hub has its own pipeline and its own image (git.shx.one/denozord/technitium-hub),
# independent of ci.yaml. A change that only touches the console's docs does not run it.
name: Hub
on:
push:
branches: [main]
tags: ['hub-v*']
paths:
- 'hub/**'
- 'src/**'
- 'public/**'
- 'index.html'
- 'package.json'
- 'package-lock.json'
- 'vite.config.ts'
- 'docker/Dockerfile.hub'
- '.gitea/workflows/hub.yaml'
workflow_dispatch:
concurrency:
group: hub-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npx vitest run hub
image:
needs: check
if: gitea.ref_name == 'main' || startsWith(gitea.ref, 'refs/tags/hub-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTION_PAT || secrets.ACTIONS_PAT }}
# The hub is not named after the repository: its own image. Registry names are lowercase.
- name: Resolve image name
id: image
run: |
owner="$(dirname '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')"
echo "name=git.shx.one/${owner}/technitium-hub" >> "$GITHUB_OUTPUT"
# A hub-v1.2.3 tag gives 1.2.3 and 1.2 (the prefix is stripped); main gives latest.
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=match,pattern=hub-v(\d+\.\d+\.\d+),group=1
type=match,pattern=hub-v(\d+\.\d+)\.\d+,group=1
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
type=sha,prefix=sha-
- uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.hub
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.image.outputs.name }}:buildcache
cache-to: type=registry,ref=${{ steps.image.outputs.name }}:buildcache,mode=max
+14 -6
View File
@@ -4,6 +4,7 @@ A stand-in for the Technitium API, with made-up data, to look at and click throu
whole console without a server and without Docker.
node dev/mock-api.mjs # listens on 127.0.0.1:5380
node dev/mock-api.mjs --port 5381 --scale 2 # another port; counters and hits times 2
npm run dev # the console, proxying /api to it
Any user and password log in. It answers the endpoints the screens call, keeps what you
@@ -14,7 +15,10 @@ behaviour: it exists so a design can be seen with data in it. The real harness i
*/
import http from 'node:http'
const PORT = Number(process.env.PORT ?? 5380)
// `--port <n>` and `--scale <x>`: two instances with different numbers stand in for two servers.
const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined }
const PORT = Number(arg('port') ?? process.env.PORT ?? 5380)
const SCALE = Number(arg('scale') ?? 1)
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const ago = (min) => iso(now - min * 60_000)
@@ -46,12 +50,12 @@ const SERIES = [
function dashboard(type) {
const r = RANGES[type] ?? RANGES.LastDay
const instants = Array.from({ length: r.n }, (_, i) => iso(now - (r.n - 1 - i) * r.step))
const wave = (base, i) => Math.max(0, Math.round(base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7))))
const wave = (base, i) => Math.max(0, Math.round(SCALE * base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7))))
const totals = Object.fromEntries(SERIES.map(([name, base]) => [name, instants.reduce((a, _, i) => a + wave(base, i), 0)]))
const stats = {
totalQueries: totals.Total, totalNoError: totals['No Error'], totalServerFailure: totals['Server Failure'], totalNxDomain: totals['NX Domain'],
totalRefused: totals.Refused, totalAuthoritative: totals.Authoritative, totalRecursive: totals.Recursive, totalCached: totals.Cached,
totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: 128, zones: zones.length, cachedEntries: 48210,
totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: Math.round(128 * SCALE), zones: zones.length, cachedEntries: 48210,
allowedZones: allowed.size, blockedZones: blocked.size, allowListZones: 1, blockListZones: 1482903,
}
return {
@@ -67,9 +71,9 @@ const CLIENTS = [['192.168.10.14', 'laptop-anna.lan'], ['192.168.10.31', 'tv-liv
const DOMAINS = ['connectivitycheck.gstatic.com', 'time.cloudflare.com', 'api.github.com', 'updates.example.net', 'www.example.net', 'mail.example.net', 'cdn.jsdelivr.net', 'fonts.googleapis.com', 'api.telegram.org', 'ocsp.digicert.com']
const BLOCKED = ['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com', 'stats.adnetwork-example.net', 'track.shop-example.org', 'ads2.tracker-example.com', 'collect.data-example.io', 'sdk.ads-example.com']
function top(kind) {
if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round(84210 / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round((SCALE * 84210) / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
const list = kind === 'domains' ? DOMAINS : BLOCKED
return list.map((name, i) => ({ name, hits: Math.round((kind === 'domains' ? 22480 : 9804) / (1 + i * 0.5)) }))
return list.map((name, i) => ({ name, hits: Math.round((SCALE * (kind === 'domains' ? 22480 : 9804)) / (1 + i * 0.5)) }))
}
// ── Zones ──────────────────────────────────────────────────────────────────
@@ -228,6 +232,10 @@ function route(path, q) {
case 'user/login': case 'user/session/get': return session
case 'user/profile/get': return ok({ displayName: 'Administrator', username: 'admin', totpEnabled: true, sessionTimeoutSeconds: 1800, memberOfGroups: ['Administrators'], sessions })
case 'user/checkForUpdate': return ok({ updateAvailable: false })
case 'dashboard/metrics/json': {
const total = Math.round(SCALE * 1_250_000)
return ok({ uptimestamp: ago(60 * 24 * 9), lifetimeCounters: { totalQueries: total, totalNoError: Math.round(total * 0.96), totalServerFailure: 40, totalNxDomain: Math.round(total * 0.03), totalRefused: 180, totalAuthoritative: Math.round(total * 0.08), totalRecursive: Math.round(total * 0.33), totalCached: Math.round(total * 0.48), totalBlocked: Math.round(total * 0.15), totalDropped: 5, totalClients: 128 } })
}
case 'dashboard/stats/get': return ok(dashboard(q.get('type') ?? 'LastDay'))
case 'dashboard/stats/getTop': return ok({ [{ TopClients: 'topClients', TopDomains: 'topDomains', TopBlockedDomains: 'topBlockedDomains' }[q.get('statsType')] ?? 'topDomains']: top({ TopClients: 'clients', TopDomains: 'domains', TopBlockedDomains: 'blocked' }[q.get('statsType')] ?? 'domains') })
case 'zones/list': {
@@ -297,4 +305,4 @@ http.createServer((req, res) => {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify(route(path, q)))
})
}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password)`))
}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password, scale ${SCALE})`))
+31
View File
@@ -0,0 +1,31 @@
# The hub image built by Gitea CI (git.shx.one): one small container that serves the
# console and a read-only, Technitium-compatible API over several real servers.
# See hub/README.md. Self-contained: it does not use the console image.
#
# docker run --rm -p 8080:8080 -e HUB_SERVERS=dns=https://dns.example.net \
# -e DNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... \
# git.shx.one/denozord/technitium-hub:latest
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# What the hub runs: its sources without the tests, and a package.json that makes the
# .ts files ECMAScript modules. It imports only node: built-ins, so there is no node_modules.
RUN mkdir -p /out/hub \
&& cp hub/*.ts /out/hub/ \
&& rm /out/hub/*.test.ts \
&& echo '{"type":"module"}' > /out/package.json
FROM node:22-alpine
WORKDIR /app
COPY --from=build /out/ ./
COPY --from=build /src/dist ./dist
ENV HUB_STATIC=/app/dist HUB_PORT=8080 NODE_ENV=production
USER node
EXPOSE 8080
# No curl in alpine: node asks the hub's public endpoint itself.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.HUB_PORT||8080)+'/api/status').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
CMD ["node", "--experimental-strip-types", "--no-warnings", "/app/hub/server.ts"]
+48
View File
@@ -0,0 +1,48 @@
version: "3.8"
# Хаб: одна консоль со статистикой нескольких серверов Technitium (см. hub/README.md).
# Подключается к уже работающему Traefik из compose.shnt.example.yaml (сеть dns-net).
#
# На КАЖДОМ сервере Technitium, один раз:
# 1. Administration > Groups: группа hub-readers, ей право только View на Dashboard, Logs,
# Apps (список приложений логирования для Query Logs) и Settings (состояние блокировки).
# 2. Administration > Users: пользователь hub-reader в этой группе.
# 3. Войти под ним и создать токен: Administration > Sessions > Create API Token.
# Токены кладутся в .env рядом с этим файлом (не в git):
# MDNS_TOKEN=...
# NDNS_TOKEN=...
# HUB_USER=... # логин в сам хаб
# HUB_PASSWORD=... # длинный пароль: у хаба нет ограничения числа попыток входа
#
# Обновление: docker compose pull technitium-hub && docker compose up -d technitium-hub
services:
technitium-hub:
image: git.shx.one/denozord/technitium-hub:latest
container_name: technitium-hub
restart: unless-stopped
networks:
- dns-net
environment:
# id=адрес, по порядку; токен каждого берётся из <ID>_TOKEN
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
- MDNS_TOKEN=${MDNS_TOKEN}
- NDNS_TOKEN=${NDNS_TOKEN}
- HUB_USER=${HUB_USER}
- HUB_PASSWORD=${HUB_PASSWORD}
- HUB_DOMAIN=dnss.shnt.top
- TZ=Asia/Novosibirsk
labels:
- "traefik.enable=true"
- "traefik.docker.network=dns-net"
- "traefik.http.routers.hub.rule=Host(`dnss.shnt.top`)"
- "traefik.http.routers.hub.entrypoints=websecure"
- "traefik.http.routers.hub.tls.certresolver=myresolver"
- "traefik.http.routers.hub.service=hub-svc"
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
networks:
dns-net:
external: true
name: dns-net
+30
View File
@@ -171,6 +171,36 @@ services:
networks:
- dns-net
# Хаб: одна консоль со статистикой обоих серверов на https://dnss.shnt.top
# (docs/hub-deploy.md). Только чтение. Токены и вход хаба берутся из .env:
# MDNS_TOKEN, NDNS_TOKEN (пользователь hub-reader на каждом сервере), HUB_USER, HUB_PASSWORD.
# Обновление: docker compose pull technitium-hub && docker compose up -d technitium-hub
technitium-hub:
image: git.shx.one/denozord/technitium-hub:latest
container_name: technitium-hub
restart: unless-stopped
networks:
- dns-net
environment:
# id=адрес по порядку; токен каждого из <ID>_TOKEN. Сервер на ЭТОМ же хосте можно
# указать изнутри сети, мимо Traefik: ndns=http://technitium-smart:5380
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
- MDNS_TOKEN=${MDNS_TOKEN}
- NDNS_TOKEN=${NDNS_TOKEN}
- HUB_USER=${HUB_USER}
- HUB_PASSWORD=${HUB_PASSWORD}
- HUB_DOMAIN=dnss.shnt.top
- TZ=Asia/Novosibirsk
labels:
- "traefik.enable=true"
- "traefik.docker.network=dns-net"
- "traefik.http.routers.hub.rule=Host(`dnss.shnt.top`)"
- "traefik.http.routers.hub.entrypoints=websecure"
- "traefik.http.routers.hub.tls.certresolver=myresolver"
- "traefik.http.routers.hub.service=hub-svc"
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
networks:
dns-net:
driver: bridge
+254
View File
@@ -0,0 +1,254 @@
# Хаб: запуск по шагам
Хаб показывает статистику двух серверов Technitium DNS (`mdns.shnt.top` и `ndns.shnt.top`) в одной консоли. Это один маленький контейнер. Он отдаёт ту же консоль и сам ходит в API обоих серверов по их токенам:
- запрос «All servers» хаб отправляет на оба сервера и складывает ответы;
- запрос к одному серверу хаб пересылает только ему;
- запись хаб не выполняет никогда.
Серверы Technitium не меняются, кластер не нужен. В инструкции хаб ставится на хост, где уже работают Traefik и `technitium-smart` (`docs/compose.shnt.example.yaml`). Адрес хаба: `https://dnss.shnt.top`.
```
браузер ─► Traefik (dnss.shnt.top, TLS) ─► technitium-hub:8080
├─► https://mdns.shnt.top/api/... (MDNS_TOKEN)
└─► https://ndns.shnt.top/api/... (NDNS_TOKEN)
```
Что хаб показывает: Dashboard (сумма или один сервер), Logs (View Logs и Query Logs, объединённые с колонкой Server) и About. Остальные разделы скрыты, ими управляют в консоли каждого сервера.
---
## Шаг 0. Образ хаба в реестре
Образ `git.shx.one/denozord/technitium-hub` собирает отдельный workflow `.gitea/workflows/hub.yaml`.
1. Отправьте коммиты хаба на `git.shx.one`:
```bash
git push origin main
```
2. В репозитории на `git.shx.one` откройте **Actions** и дождитесь зелёного прогона **Hub**:
- job `check` проверяет типы и тесты хаба;
- job `image` собирает образ и кладёт его в реестр.
3. Убедитесь, что образ появился: в репозитории или профиле откройте **Packages**, там должен быть `technitium-hub` с тегом `latest`.
Если job `image` упал на входе в реестр, проверьте секрет `ACTION_PAT` (или `ACTIONS_PAT`) в настройках репозитория: это токен Gitea с правом `write:package`. Он тот же, что использует образ консоли.
Выпуск версии с номером (необязательно):
```bash
git tag hub-v1.0.0 && git push origin hub-v1.0.0
```
Тег даёт образы `1.0.0` и `1.0` в дополнение к `latest`.
---
## Шаг 1. Пользователь только для чтения на каждом сервере
Сделайте это **на обоих серверах**: `https://mdns.shnt.top` и `https://ndns.shnt.top`. Войдите в консоль сервера как администратор.
1. **Группа.** Administration › Groups › **Add Group**: имя `hub-readers`, описание любое.
2. **Права группы.** Administration › Permissions: у группы `hub-readers` отметьте только **View** в четырёх разделах:
| Раздел | Зачем хабу |
|---|---|
| Dashboard | статистика, топы, график по часам, живые метрики |
| Logs | список файлов логов и Query Logs |
| Apps | список приложений логирования, из которых Query Logs читает записи |
| Settings | состояние блокировки на Dashboard |
Modify и Delete не отмечайте нигде. Остальные разделы оставьте без прав.
3. **Пользователь.** Administration › Users › **Add User**: имя `hub-reader`, длинный случайный пароль. Затем в карточке пользователя добавьте его в группу `hub-readers`. Из группы `Everyone` можно убрать, если у неё есть лишние права.
4. **API-токен.** Нужен токен именно для `hub-reader`, а не для администратора:
- выйдите из консоли и войдите под `hub-reader`;
- в меню пользователя внизу слева выберите **Create API Token**, имя токена `hub`;
- скопируйте токен сразу: его показывают один раз.
Можно и под администратором: Administration › Sessions › **Create Token**, пользователь `hub-reader`.
5. Проверьте токен с любой машины. В ответе должно быть `"status":"ok"`:
```bash
curl -s "https://mdns.shnt.top/api/dashboard/stats/get?type=LastHour&token=ТОКЕН" | head -c 200
```
- `"status":"invalid-token"` значит, что скопирован не тот токен;
- `"status":"error"` с сообщением об отказе в доступе значит, что у группы не хватает права View.
Результат шага: два токена, `MDNS_TOKEN` (с `mdns.shnt.top`) и `NDNS_TOKEN` (с `ndns.shnt.top`).
---
## Шаг 2. DNS-запись для dnss.shnt.top
В Cloudflare (зона `shnt.top`) создайте запись `dnss` типа `A` на IP хоста, где работает хаб.
Сертификат Traefik получит сам. В compose уже настроен `certresolver=myresolver` с DNS-challenge через Cloudflare (`CF_DNS_API_TOKEN`), отдельно открывать порт 80 для проверки не нужно.
---
## Шаг 3. Файл .env
На хосте в каталоге с `docker-compose.yml` откройте `.env` (там уже лежат `CF_DNS_API_TOKEN` и `DNS_SERVER_ADMIN_PASSWORD`) и добавьте:
```dotenv
# Хаб: токены пользователей hub-reader с шага 1
MDNS_TOKEN=вставьте_токен_mdns
NDNS_TOKEN=вставьте_токен_ndns
# Вход в сам хаб (это НЕ логин Technitium)
HUB_USER=admin
HUB_PASSWORD=длинный_случайный_пароль
```
Пароль хаба нужен длинный: у хаба нет ограничения числа попыток входа. Сгенерировать можно так:
```bash
openssl rand -base64 24
```
Закройте `.env` от посторонних:
```bash
chmod 600 .env
```
---
## Шаг 4. Сервис в compose
Добавьте в `docker-compose.yml` в раздел `services:` блок из `docs/compose.hub.example.yaml`:
```yaml
technitium-hub:
image: git.shx.one/denozord/technitium-hub:latest
container_name: technitium-hub
restart: unless-stopped
networks:
- dns-net
environment:
# id=адрес, по порядку; токен каждого берётся из <ID>_TOKEN
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
- MDNS_TOKEN=${MDNS_TOKEN}
- NDNS_TOKEN=${NDNS_TOKEN}
- HUB_USER=${HUB_USER}
- HUB_PASSWORD=${HUB_PASSWORD}
- HUB_DOMAIN=dnss.shnt.top
- TZ=Asia/Novosibirsk
labels:
- "traefik.enable=true"
- "traefik.docker.network=dns-net"
- "traefik.http.routers.hub.rule=Host(`dnss.shnt.top`)"
- "traefik.http.routers.hub.entrypoints=websecure"
- "traefik.http.routers.hub.tls.certresolver=myresolver"
- "traefik.http.routers.hub.service=hub-svc"
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
```
Если хаб стоит в том же файле, что Traefik, отдельный блок `networks:` с `external: true` из примера не нужен: сеть `dns-net` в этом файле уже объявлена.
Про имена в `HUB_SERVERS`:
- имя слева от `=` видно в консоли в селекторе «Server»;
- из имени получается переменная токена: `mdns` даёт `MDNS_TOKEN`, `ndns` даёт `NDNS_TOKEN`, `my-dns` дал бы `MY_DNS_TOKEN`;
- первый в списке сервер считается основным.
Адреса серверов указываются публичные (`https://mdns.shnt.top`). API Technitium доступно по тому же адресу, что и консоль (роутер `panel-old` в Traefik), отдельный порт не нужен.
---
## Шаг 5. Запуск
Если реестр закрытый, один раз войдите в него. Нужен токен Gitea с правом `read:package`:
```bash
docker login git.shx.one
```
Скачайте образ и запустите хаб:
```bash
docker compose pull technitium-hub
docker compose up -d technitium-hub
```
Проверьте контейнер:
```bash
docker compose ps technitium-hub # STATUS: Up ... (healthy)
docker compose logs -f technitium-hub
```
В логе при старте не должно быть ошибок конфигурации. Если чего-то не хватает, хаб не запустится и назовёт переменную, например `NDNS_TOKEN is required for the server "ndns".`
---
## Шаг 6. Проверка
1. Откройте `https://dnss.shnt.top` и войдите с `HUB_USER` / `HUB_PASSWORD` из `.env`.
2. На Dashboard над заголовком должен быть селектор **Server: All servers**.
3. Сверьте цифры. Total Queries в «All servers» должна равняться сумме значений в консолях `mdns.shnt.top` и `ndns.shnt.top` за тот же период. Расхождение в пределах минуты нормально: хаб кеширует ответы на 15 секунд, а Last Hour обновляется на ходу.
4. Выберите в селекторе `mdns`, затем `ndns`: цифры каждого сервера должны совпасть с его собственной консолью.
5. Откройте Logs › Query Logs, нажмите **Query**. Записи обоих серверов идут вместе, в колонке **Server** видно, чья запись.
6. Кнопка статуса справа в шапке показывает `dnss.shnt.top`. Если какой-то сервер не ответил, там будет «1 server not answering», а по нажатию появится причина.
Проверка без браузера (подставьте пароль хаба):
```bash
T=$(curl -s -X POST https://dnss.shnt.top/api/user/login \
--data-urlencode "user=admin" --data-urlencode "pass=ПАРОЛЬ_ХАБА" | sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -s -H "Authorization: Bearer $T" "https://dnss.shnt.top/api/dashboard/stats/get?type=LastHour" | head -c 300
curl -s -H "Authorization: Bearer $T" "https://dnss.shnt.top/api/dashboard/stats/get?type=LastHour&node=ndns" | head -c 300
```
---
## Обновление
После нового прогона **Hub** в Actions:
```bash
docker compose pull technitium-hub && docker compose up -d technitium-hub
```
Сессии хаба хранятся в памяти: после перезапуска контейнера нужно войти заново.
Чтобы закрепить версию, укажите в compose тег вместо `latest`, например `technitium-hub:1.0.0`.
## Остановка и удаление
```bash
docker compose stop technitium-hub # остановить
docker compose rm -f technitium-hub # удалить контейнер
```
Чтобы отозвать доступ хаба к серверам, на каждом сервере удалите токен в Administration › Sessions или отключите пользователя `hub-reader`.
---
## Настройки хаба
| Переменная | По умолчанию | Что задаёт |
|---|---|---|
| `HUB_SERVERS` | обязательна | Серверы по порядку: `id=https://адрес,...` |
| `<ID>_TOKEN` | обязательна для каждого | API-токен сервера (`MDNS_TOKEN`, `NDNS_TOKEN`, ...) |
| `HUB_USER`, `HUB_PASSWORD` | обязательны | Вход в хаб |
| `HUB_DOMAIN` | `hub` | Имя, которое консоль показывает как имя сервера |
| `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) |
| `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс |
| `HUB_PORT` | `8080` | Порт внутри контейнера |
Третий сервер добавляется строкой в `HUB_SERVERS` и его токеном:
```yaml
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top,lab=https://lab.shnt.top
- LAB_TOKEN=${LAB_TOKEN}
```
---
## Если что-то не так
| Признак | Причина и что делать |
|---|---|
| Контейнер сразу завершается | В `docker compose logs technitium-hub` будет названа недостающая переменная. Проверьте `.env` и что compose его видит (`docker compose config \| grep TOKEN`). |
| `https://dnss.shnt.top` не открывается, ошибка сертификата | Нет DNS-записи `dnss` (шаг 2) или Traefik ещё получает сертификат: смотрите `docker logs traefik`. |
| 404 от Traefik | Контейнер не в сети `dns-net` или опечатка в `Host(\`dnss.shnt.top\`)`. |
| Вход в хаб: «Invalid username or password» | Неверные `HUB_USER`/`HUB_PASSWORD` в `.env`. После правки `.env` нужен `docker compose up -d technitium-hub`. |
| В шапке «1 server not answering», в подсказке `ndns: its API token was refused` | Токен `NDNS_TOKEN` отозван или скопирован с ошибкой. Создайте новый (шаг 1.4) и перезапустите хаб. |
| В подсказке `... did not answer: ...` или `ECONNREFUSED` / `timeout` | Хаб не достучался до сервера. Проверьте с хоста хаба: `docker exec technitium-hub node -e "fetch('https://ndns.shnt.top/api/status').then(r=>console.log(r.status))"`. |
| В подсказке `ndns: ...` с сообщением об отказе в доступе | У группы `hub-readers` на этом сервере не хватает View в каком-то разделе (шаг 1.2). |
| Query Logs пустой или «нет приложения» | На сервере не установлено приложение логирования (Query Logs (Sqlite) и подобные) или нет права View на Apps. |
| Цифры «All servers» больше суммы на 1-2 клиента | Clients считается по каждому серверу отдельно: клиент, который ходит на оба сервера, учтён дважды. Это ожидаемо. |
| Нажатие на что-то пишет «The hub is read-only.» | Хаб только читает. Изменения делаются в консоли нужного сервера. |
## Безопасность
- Хаб только читает: любая запись получает отказ ещё в хабе, а токены выданы пользователю с одними правами View.
- Токены лежат только в `.env` на хосте и в памяти контейнера. В браузер они не попадают, в ответы и логи хаба не пишутся.
- У хаба один общий логин и нет ограничения попыток входа. Нужен длинный пароль, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`.
+63
View File
@@ -0,0 +1,63 @@
# 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`. The hub has its own sign-in.
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` | required | The hub's own sign-in. It refuses to start without them. |
| `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. |
## 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).
Everything else answers `403 The hub is read-only.`
## 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_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=... \
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`.
+81
View File
@@ -0,0 +1,81 @@
/*
The hub's configuration, read from the environment. `parseConfig` is pure so the
tests can feed it any environment; `loadConfig` is the one that reads `process.env`.
*/
export const HUB_VERSION = '1.0.0'
export interface HubServer {
id: string
url: string
token: string
}
export interface HubConfig {
servers: HubServer[]
user: string
password: string
port: number
cacheSeconds: number
timeoutMs: number
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
domain: string
/** The console's static build. */
staticDir: string
}
type Env = Record<string, string | undefined>
/* `ndns` -> `NDNS_TOKEN`, `my-dns.1` -> `MY_DNS_1_TOKEN`. */
export function tokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN`
}
function number(env: Env, name: string, fallback: number, min: number): number {
const raw = env[name]
if (raw === undefined || raw.trim() === '') return fallback
const value = Number(raw)
if (!Number.isFinite(value) || value < min) throw new Error(`${name} must be a number of at least ${min}.`)
return value
}
export function parseConfig(env: Env): HubConfig {
const user = env.HUB_USER ?? ''
const password = env.HUB_PASSWORD ?? ''
if (user === '' || password === '') throw new Error('HUB_USER and HUB_PASSWORD are required.')
const servers: HubServer[] = []
for (const part of (env.HUB_SERVERS ?? '').split(',')) {
const entry = part.trim()
if (entry === '') continue
const at = entry.indexOf('=')
const id = at < 0 ? '' : entry.slice(0, at).trim()
const url = at < 0 ? '' : entry.slice(at + 1).trim().replace(/\/+$/, '')
if (!/^[A-Za-z0-9._-]+$/.test(id) || id === 'cluster' || !/^https?:\/\/[^/]/.test(url)) {
throw new Error(`HUB_SERVERS entry "${entry}" is not of the form id=https://host (id: letters, digits, . _ -).`)
}
if (servers.some((s) => s.id === id)) throw new Error(`HUB_SERVERS lists "${id}" twice.`)
const variable = tokenVariable(id)
const token = env[variable] ?? ''
if (token === '') throw new Error(`${variable} is required for the server "${id}".`)
servers.push({ id, url, token })
}
if (servers.length === 0) {
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
}
return {
servers,
user,
password,
port: number(env, 'HUB_PORT', 8080, 0),
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
domain: env.HUB_DOMAIN?.trim() || 'hub',
staticDir: env.HUB_STATIC?.trim() || './dist',
}
}
export function loadConfig(): HubConfig {
return parseConfig(process.env)
}
+341
View File
@@ -0,0 +1,341 @@
import { describe, expect, it } from 'vitest'
import {
mergeQueryLogs,
mergeSettled,
mergeStats,
mergeTop,
mergeTopResponses,
type ChartData,
type DashboardStats,
type QueryLogEntry,
type QueryLogPage,
type Settled,
} from './merge.ts'
/* Fixtures in the shape of `dashboard/stats/get` (see dev/mock-api.mjs). */
function stats(over: Partial<Record<string, number>> = {}): Record<string, number> {
return {
totalQueries: 100, totalNoError: 90, totalServerFailure: 1, totalNxDomain: 5, totalRefused: 4,
totalAuthoritative: 10, totalRecursive: 30, totalCached: 50, totalBlocked: 8, totalDropped: 2,
totalClients: 12, zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1,
blockListZones: 1000, ...over,
} as Record<string, number>
}
function response(over: Partial<DashboardStats> = {}): DashboardStats {
return {
stats: stats(),
mainChartData: {
labelFormat: 'HH:mm',
labels: ['2026-01-01T10:00:00Z', '2026-01-01T11:00:00Z'],
datasets: [{ label: 'Total', data: [40, 60] }, { label: 'Blocked', data: [3, 5] }],
},
queryResponseChartData: {
labels: ['Authoritative', 'Recursive', 'Cached'],
datasets: [{ label: 'Total', data: [10, 30, 50], backgroundColor: ['#111', '#222', '#333'] }],
},
queryTypeChartData: { labels: ['A', 'AAAA'], datasets: [{ label: 'Total', data: [70, 30], backgroundColor: [] }] },
protocolTypeChartData: { labels: ['UDP', 'TCP'], datasets: [{ label: 'Total', data: [90, 10], backgroundColor: [] }] },
topClients: [{ name: '10.0.0.1', domain: 'a.lan', hits: 50 }],
topDomains: [{ name: 'example.com', hits: 30 }],
topBlockedDomains: [{ name: 'ads.example.com', hits: 8 }],
...over,
}
}
describe('mergeStats counters', () => {
it('sums every numeric field of stats', () => {
const merged = mergeStats([response(), response({ stats: stats({ totalQueries: 250, zones: 7 }) })])
expect(merged.stats.totalQueries).toBe(350)
expect(merged.stats.totalNoError).toBe(180)
expect(merged.stats.zones).toBe(10)
expect(merged.stats.blockListZones).toBe(2000)
expect(Object.keys(merged.stats)).toEqual(Object.keys(stats()))
})
it('returns a single response with the same numbers', () => {
const merged = mergeStats([response()])
expect(merged.stats).toEqual(stats())
expect(merged.mainChartData?.datasets[0].data).toEqual([40, 60])
})
it('throws on nothing to merge', () => {
expect(() => mergeStats([])).toThrow()
})
it('does not mutate its inputs', () => {
const a = response()
const b = response()
const before = JSON.stringify([a, b])
mergeStats([a, b])
expect(JSON.stringify([a, b])).toBe(before)
})
})
describe('mergeStats main chart', () => {
it('sums the values of equal labels per dataset', () => {
const merged = mergeStats([response(), response()])
expect(merged.mainChartData?.labels).toEqual(['2026-01-01T10:00:00Z', '2026-01-01T11:00:00Z'])
expect(merged.mainChartData?.datasets).toEqual([
{ label: 'Total', data: [80, 120] },
{ label: 'Blocked', data: [6, 10] },
])
expect(merged.mainChartData?.labelFormat).toBe('HH:mm')
})
it('aligns differing label sets by label, missing points count as 0, order stays chronological', () => {
const a: ChartData = { labels: ['t1', 't2', 't4'], datasets: [{ label: 'Total', data: [1, 2, 4] }] }
const b: ChartData = { labels: ['t2', 't3', 't4', 't5'], datasets: [{ label: 'Total', data: [20, 30, 40, 50] }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.labels).toEqual(['t1', 't2', 't3', 't4', 't5'])
expect(merged.mainChartData?.datasets[0].data).toEqual([1, 22, 30, 44, 50])
})
it('slips earlier labels of a later server before the first shared label', () => {
const a: ChartData = { labels: ['t3', 't4'], datasets: [{ label: 'Total', data: [3, 4] }] }
const b: ChartData = { labels: ['t1', 't2', 't3'], datasets: [{ label: 'Total', data: [10, 20, 30] }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.labels).toEqual(['t1', 't2', 't3', 't4'])
expect(merged.mainChartData?.datasets[0].data).toEqual([10, 20, 33, 4])
})
it('matches datasets by label, not by position', () => {
const a: ChartData = {
labels: ['t1'],
datasets: [{ label: 'Total', data: [5] }, { label: 'Blocked', data: [1] }],
}
const b: ChartData = {
labels: ['t1'],
datasets: [{ label: 'Blocked', data: [10] }, { label: 'Cached', data: [7] }, { label: 'Total', data: [50] }],
}
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.datasets).toEqual([
{ label: 'Total', data: [55] },
{ label: 'Blocked', data: [11] },
{ label: 'Cached', data: [7] },
])
})
it('keeps a dataset that only one server has, zero where the other labels are', () => {
const a: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [5] }] }
const b: ChartData = { labels: ['t2'], datasets: [{ label: 'Cached', data: [7] }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.labels).toEqual(['t1', 't2'])
expect(merged.mainChartData?.datasets).toEqual([
{ label: 'Total', data: [5, 0] },
{ label: 'Cached', data: [0, 7] },
])
})
it('keeps the other properties of the first dataset that has them', () => {
const a: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [1], backgroundColor: '#aaa' }] }
const b: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [2], backgroundColor: '#bbb' }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.datasets[0]).toEqual({ label: 'Total', data: [3], backgroundColor: '#aaa' })
})
it('survives a server without a main chart', () => {
const merged = mergeStats([response({ mainChartData: undefined }), response()])
expect(merged.mainChartData?.datasets[0].data).toEqual([40, 60])
})
})
describe('mergeStats breakdown charts', () => {
it('sums values per label and keeps the colours of the first server', () => {
const merged = mergeStats([response(), response()])
expect(merged.queryResponseChartData).toEqual({
labels: ['Authoritative', 'Recursive', 'Cached'],
datasets: [{ label: 'Total', data: [20, 60, 100], backgroundColor: ['#111', '#222', '#333'] }],
})
expect(merged.queryTypeChartData?.datasets[0].data).toEqual([140, 60])
expect(merged.protocolTypeChartData?.datasets[0].data).toEqual([180, 20])
})
it('keeps the label order of the first server and appends new labels', () => {
const a: ChartData = { labels: ['A', 'AAAA'], datasets: [{ label: 'Total', data: [1, 2] }] }
const b: ChartData = { labels: ['TXT', 'AAAA', 'A', 'MX'], datasets: [{ label: 'Total', data: [10, 20, 30, 40] }] }
const merged = mergeStats([response({ queryTypeChartData: a }), response({ queryTypeChartData: b })])
expect(merged.queryTypeChartData?.labels).toEqual(['A', 'AAAA', 'TXT', 'MX'])
expect(merged.queryTypeChartData?.datasets[0].data).toEqual([31, 22, 10, 40])
})
})
describe('mergeStats top lists', () => {
it('merges the three top lists inside the stats', () => {
const a = response({
topClients: [{ name: '10.0.0.1', domain: '', hits: 50 }, { name: '10.0.0.2', hits: 10, rateLimited: true }],
topDomains: [{ name: 'a.com', hits: 5 }],
})
const b = response({
topClients: [{ name: '10.0.0.1', domain: 'host.lan', hits: 25 }],
topDomains: [{ name: 'b.com', hits: 9 }],
topBlockedDomains: [{ name: 'ads.example.com', hits: 2 }],
})
const merged = mergeStats([a, b])
expect(merged.topClients).toEqual([
{ name: '10.0.0.1', domain: 'host.lan', hits: 75 },
{ name: '10.0.0.2', hits: 10, rateLimited: true },
])
// The default limit is the longest input list (1 here).
expect(merged.topDomains).toEqual([{ name: 'b.com', hits: 9 }])
expect(merged.topBlockedDomains).toEqual([{ name: 'ads.example.com', hits: 10 }])
})
})
describe('mergeTop', () => {
it('sums hits by name, sorts by hits and cuts to the longest input by default', () => {
const a = [{ name: 'a', hits: 10 }, { name: 'b', hits: 5 }, { name: 'c', hits: 1 }]
const b = [{ name: 'c', hits: 20 }, { name: 'd', hits: 7 }]
expect(mergeTop([a, b])).toEqual([
{ name: 'c', hits: 21 },
{ name: 'a', hits: 10 },
{ name: 'd', hits: 7 },
])
})
it('cuts to an explicit limit', () => {
const a = [{ name: 'a', hits: 10 }, { name: 'b', hits: 9 }]
const b = [{ name: 'c', hits: 8 }, { name: 'd', hits: 7 }]
expect(mergeTop([a, b], 3).map((e) => e.name)).toEqual(['a', 'b', 'c'])
expect(mergeTop([a, b], 0)).toEqual([])
})
it('breaks ties by name ascending', () => {
const a = [{ name: 'zeta', hits: 5 }, { name: 'alpha', hits: 5 }]
const b = [{ name: 'mid', hits: 5 }]
expect(mergeTop([a, b], 10).map((e) => e.name)).toEqual(['alpha', 'mid', 'zeta'])
})
it('keeps the first non-empty domain and ORs rateLimited', () => {
const a = [{ name: 'c1', domain: '', hits: 1 }]
const b = [{ name: 'c1', domain: 'first.lan', hits: 1, rateLimited: true }]
const c = [{ name: 'c1', domain: 'second.lan', hits: 1 }]
expect(mergeTop([a, b, c])).toEqual([{ name: 'c1', domain: 'first.lan', hits: 3, rateLimited: true }])
})
it('ignores missing lists and does not mutate the inputs', () => {
const a = [{ name: 'a', hits: 1 }]
expect(mergeTop([a, undefined, a])).toEqual([{ name: 'a', hits: 2 }])
expect(a).toEqual([{ name: 'a', hits: 1 }])
expect(mergeTop([])).toEqual([])
})
})
describe('mergeTopResponses', () => {
it('merges the getTop shape under the key the servers used', () => {
const merged = mergeTopResponses([
{ topClients: [{ name: 'x', hits: 3 }] },
{ topClients: [{ name: 'x', hits: 4 }, { name: 'y', hits: 1 }] },
], 1)
expect(merged).toEqual({ topClients: [{ name: 'x', hits: 7 }] })
})
it('works for domains and blocked domains', () => {
expect(mergeTopResponses([{ topDomains: [{ name: 'a', hits: 1 }] }, { topDomains: [{ name: 'a', hits: 1 }] }]))
.toEqual({ topDomains: [{ name: 'a', hits: 2 }] })
expect(mergeTopResponses([{ topBlockedDomains: [{ name: 'b', hits: 1 }] }]))
.toEqual({ topBlockedDomains: [{ name: 'b', hits: 1 }] })
})
})
/* Fixtures in the shape of `logs/query` (see dev/mock-api.mjs). */
function entry(timestamp: string, qname: string): QueryLogEntry {
return {
rowNumber: 1, timestamp, clientIpAddress: '192.168.10.14', protocol: 'Udp', responseType: 'Cached',
responseRtt: 1.5, rcode: 'NoError', qname, qtype: 'A', qclass: 'IN', answer: '1.1.1.1',
}
}
function page(entries: QueryLogEntry[], totalEntries = entries.length): QueryLogPage {
return { pageNumber: 1, totalPages: 1, totalEntries, entries }
}
describe('mergeQueryLogs', () => {
const dns = page([entry('2026-01-01T10:00:03Z', 'a.com'), entry('2026-01-01T10:00:01Z', 'c.com')])
const ndns = page([entry('2026-01-01T10:00:04Z', 'b.com'), entry('2026-01-01T10:00:02Z', 'd.com')])
const pages = [{ node: 'dns', page: dns }, { node: 'ndns', page: ndns }]
it('sorts newest first by default and tags each entry with its server', () => {
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 25 })
expect(merged.entries.map((e) => e.qname)).toEqual(['b.com', 'a.com', 'd.com', 'c.com'])
expect(merged.entries.map((e) => e.node)).toEqual(['ndns', 'dns', 'ndns', 'dns'])
expect(merged.entries[0]).toMatchObject({ clientIpAddress: '192.168.10.14', answer: '1.1.1.1', responseRtt: 1.5 })
})
it('sorts oldest first when descending is false', () => {
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 25, descending: false })
expect(merged.entries.map((e) => e.qname)).toEqual(['c.com', 'd.com', 'a.com', 'b.com'])
})
it('renumbers rows from 1 and sums the totals', () => {
const merged = mergeQueryLogs(
[{ node: 'dns', page: page(dns.entries, 120) }, { node: 'ndns', page: page(ndns.entries, 31) }],
{ pageNumber: 1, entriesPerPage: 25 },
)
expect(merged.entries.map((e) => e.rowNumber)).toEqual([1, 2, 3, 4])
expect(merged.totalEntries).toBe(151)
expect(merged.totalPages).toBe(7)
expect(merged.pageNumber).toBe(1)
})
it('cuts page N from the merged head of every server and numbers it from its offset', () => {
// Each server sent its first 2 x 2 entries; page 2 is the 3rd and 4th of the merged list.
const merged = mergeQueryLogs(pages, { pageNumber: 2, entriesPerPage: 2 })
expect(merged.pageNumber).toBe(2)
expect(merged.entries.map((e) => e.qname)).toEqual(['d.com', 'c.com'])
expect(merged.entries.map((e) => e.rowNumber)).toEqual([3, 4])
})
it('a page past the end is empty', () => {
expect(mergeQueryLogs(pages, { pageNumber: 3, entriesPerPage: 2 }).entries).toEqual([])
})
it('cuts to entriesPerPage', () => {
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 3 })
expect(merged.entries.map((e) => e.qname)).toEqual(['b.com', 'a.com', 'd.com'])
expect(merged.totalEntries).toBe(4)
expect(merged.totalPages).toBe(2)
})
it('keeps server order for equal timestamps', () => {
const same = [
{ node: 'dns', page: page([entry('2026-01-01T10:00:00Z', 'x.com')]) },
{ node: 'ndns', page: page([entry('2026-01-01T10:00:00Z', 'y.com')]) },
]
expect(mergeQueryLogs(same, { pageNumber: 1, entriesPerPage: 10 }).entries.map((e) => e.node)).toEqual(['dns', 'ndns'])
})
it('handles empty pages', () => {
const merged = mergeQueryLogs([{ node: 'dns', page: page([]) }], { pageNumber: 1, entriesPerPage: 25 })
expect(merged).toEqual({ pageNumber: 1, totalPages: 0, totalEntries: 0, entries: [] })
})
it('does not mutate the input entries', () => {
mergeQueryLogs(pages, { pageNumber: 2, entriesPerPage: 2 })
expect(dns.entries[0].node).toBeUndefined()
expect(dns.entries[0].rowNumber).toBe(1)
})
})
describe('mergeSettled', () => {
it('returns the data of the servers that answered', () => {
const results: Settled<number>[] = [{ node: 'dns', ok: true, data: 1 }, { node: 'ndns', ok: true, data: 2 }]
expect(mergeSettled(results)).toEqual({ data: [1, 2], warnings: [] })
})
it('warns about an unreachable server and keeps the other one', () => {
const results: Settled<number>[] = [
{ node: 'dns', ok: true, data: 1 },
{ node: 'ndns', ok: false, error: 'timeout' },
]
expect(mergeSettled(results)).toEqual({ data: [1], warnings: ['ndns: timeout'] })
})
it('throws naming every server when all failed', () => {
const results: Settled<number>[] = [
{ node: 'dns', ok: false, error: 'refused' },
{ node: 'ndns', ok: false, error: 'timeout' },
]
expect(() => mergeSettled(results)).toThrow(/dns: refused.*ndns: timeout/)
})
})
+245
View File
@@ -0,0 +1,245 @@
/*
Pure merge functions of the hub: they take the `response` objects of several
Technitium servers and return the one a single server would have sent, so the
console can show "All servers" without knowing there are two.
No imports, no I/O: it runs under `node --experimental-strip-types` and is tested
on its own. The types are declared here and mirror `src/api/dashboard.ts` and
`src/api/logs.ts` on purpose, so the hub never pulls the console's sources in.
*/
export interface ChartDataset {
label: string
data: number[]
backgroundColor?: string | string[]
[key: string]: unknown
}
export interface ChartData {
labelFormat?: string
labels: string[]
datasets: ChartDataset[]
[key: string]: unknown
}
export interface TopEntry {
name: string
hits: number
domain?: string
rateLimited?: boolean
[key: string]: unknown
}
export interface DashboardStats {
stats: Record<string, number>
mainChartData?: ChartData
queryResponseChartData?: ChartData
queryTypeChartData?: ChartData
protocolTypeChartData?: ChartData
topClients?: TopEntry[]
topDomains?: TopEntry[]
topBlockedDomains?: TopEntry[]
[key: string]: unknown
}
export interface QueryLogEntry {
rowNumber: number
timestamp: string
node?: string
[key: string]: unknown
}
export interface QueryLogPage {
pageNumber: number
totalPages: number
totalEntries: number
entries: QueryLogEntry[]
}
export type Settled<T> = { node: string; ok: true; data: T } | { node: string; ok: false; error: string }
const TOP_KEYS = ['topClients', 'topDomains', 'topBlockedDomains'] as const
const BREAKDOWN_KEYS = ['queryResponseChartData', 'queryTypeChartData', 'protocolTypeChartData'] as const
/*
Charts. A chart is a list of labels and datasets whose `data` lines up with the
labels. Merging means: build the merged label list, then for each dataset (matched
by its `label`) add each server's value into the slot of its label. A label a
server lacks counts as 0.
`ordered` is for the time series: the merged labels keep chronological order, new
labels are slipped in before the next label both lists share. Without it (the
breakdowns) the labels of the first server keep their order and new ones are
appended.
*/
function mergeLabels(charts: ChartData[], ordered: boolean): string[] {
const labels: string[] = [...charts[0].labels]
for (const chart of charts.slice(1)) {
if (!ordered) {
for (const label of chart.labels) if (!labels.includes(label)) labels.push(label)
continue
}
// Labels before the first shared one go right before it; with none shared, all are appended.
const anchor = chart.labels.findIndex((label) => labels.includes(label))
if (anchor < 0) {
labels.push(...chart.labels)
continue
}
let pos = labels.indexOf(chart.labels[anchor])
for (const label of chart.labels) {
const at = labels.indexOf(label)
if (at >= 0) {
pos = at + 1
} else {
labels.splice(pos, 0, label)
pos += 1
}
}
}
return labels
}
function mergeChart(charts: (ChartData | undefined)[], ordered: boolean): ChartData | undefined {
const present = charts.filter((c): c is ChartData => c != null && Array.isArray(c.labels))
if (present.length === 0) return undefined
const labels = mergeLabels(present, ordered)
const slot = new Map(labels.map((label, i) => [label, i]))
const merged = new Map<string, ChartDataset>()
for (const chart of present) {
for (const dataset of chart.datasets ?? []) {
let target = merged.get(dataset.label)
if (target === undefined) {
// The other properties (colours...) come from the first server that has the dataset.
target = { ...dataset, data: labels.map(() => 0) }
merged.set(dataset.label, target)
}
const into = target
chart.labels.forEach((label, i) => {
const value = dataset.data?.[i]
if (typeof value === 'number') into.data[slot.get(label) as number] += value
})
}
}
return { ...present[0], labels, datasets: [...merged.values()] }
}
/*
Top lists: hits are summed by name, ordered by hits (highest first) then by name,
and cut to `limit`, which defaults to the longest input list. For a client, the
first non-empty `domain` is kept and `rateLimited` is true if any server said so.
*/
export function mergeTop(lists: (TopEntry[] | undefined)[], limit?: number): TopEntry[] {
const present = lists.filter((l): l is TopEntry[] => Array.isArray(l))
const byName = new Map<string, TopEntry>()
for (const list of present) {
for (const entry of list) {
const seen = byName.get(entry.name)
if (seen === undefined) {
byName.set(entry.name, { ...entry })
continue
}
seen.hits += entry.hits
if (!seen.domain && entry.domain) seen.domain = entry.domain
if (entry.rateLimited) seen.rateLimited = true
}
}
const cut = limit ?? Math.max(0, ...present.map((l) => l.length))
return [...byName.values()]
.sort((a, b) => b.hits - a.hits || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
.slice(0, cut)
}
/* The `dashboard/stats/getTop` response is `{ topClients | topDomains | topBlockedDomains: [...] }`. */
export function mergeTopResponses(
responses: Record<string, TopEntry[] | undefined>[],
limit?: number,
): Record<string, TopEntry[]> {
for (const key of TOP_KEYS) {
if (responses.some((r) => Array.isArray(r[key]))) return { [key]: mergeTop(responses.map((r) => r[key]), limit) }
}
return { topDomains: [] }
}
/*
`dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes
`totalClients` (distinct clients), which over-counts a client seen by both servers:
the hub has no way to know the overlap from the totals alone.
*/
export function mergeStats(responses: DashboardStats[]): DashboardStats {
if (responses.length === 0) throw new Error('Nothing to merge.')
const first = responses[0]
const stats: Record<string, number> = {}
for (const r of responses) {
for (const [key, value] of Object.entries(r.stats ?? {})) {
if (typeof value === 'number') stats[key] = (stats[key] ?? 0) + value
}
}
const merged: DashboardStats = { ...first, stats }
const main = mergeChart(responses.map((r) => r.mainChartData), true)
if (main) merged.mainChartData = main
for (const key of BREAKDOWN_KEYS) {
const chart = mergeChart(responses.map((r) => r[key]), false)
if (chart) merged[key] = chart
}
for (const key of TOP_KEYS) {
if (responses.some((r) => Array.isArray(r[key]))) merged[key] = mergeTop(responses.map((r) => r[key]))
}
return merged
}
/*
`logs/query`. The pages are what each server answered; the entries are sorted by
timestamp together, each tagged with the server it came from. To build page N the
caller asks every server for ITS first N x `entriesPerPage` entries (page 1 of that
size): page N of the merged list can only be cut from the merged head of all of them.
The hub keeps the slice of page N. `rowNumber` continues from the page's offset, as a
single server numbers it.
*/
export function mergeQueryLogs(
pages: { node: string; page: QueryLogPage }[],
opts: { pageNumber: number; entriesPerPage: number; descending?: boolean },
): QueryLogPage {
const descending = opts.descending !== false
const entries: QueryLogEntry[] = []
let totalEntries = 0
for (const { node, page } of pages) {
totalEntries += page.totalEntries
for (const entry of page.entries) entries.push({ ...entry, node })
}
// Array.prototype.sort is stable: equal timestamps keep the order of the servers.
const time = (e: QueryLogEntry) => {
const t = Date.parse(e.timestamp)
return Number.isNaN(t) ? 0 : t
}
entries.sort((a, b) => {
const diff = time(a) - time(b)
return descending ? -diff : diff
})
const offset = (opts.pageNumber - 1) * opts.entriesPerPage
return {
pageNumber: opts.pageNumber,
totalPages: Math.ceil(totalEntries / opts.entriesPerPage),
totalEntries,
entries: entries
.slice(offset, offset + opts.entriesPerPage)
.map((entry, i) => ({ ...entry, rowNumber: offset + i + 1 })),
}
}
/* Settled results of the parallel calls: the data that arrived plus a warning per unreachable server. */
export function mergeSettled<T>(results: Settled<T>[]): { data: T[]; warnings: string[] } {
const data: T[] = []
const warnings: string[] = []
for (const result of results) {
if (result.ok) data.push(result.data)
else warnings.push(`${result.node}: ${result.error}`)
}
if (data.length === 0) throw new Error(`No server answered. ${warnings.join('; ')}`.trim())
return { data, warnings }
}
+341
View File
@@ -0,0 +1,341 @@
// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
/* A fake Technitium: answers `canned(path, query)` and records every request. */
interface Fake {
server: http.Server
url: string
calls: { path: string; query: URLSearchParams }[]
canned: (path: string, query: URLSearchParams) => unknown
down: boolean
}
function fakeUpstream(canned: Fake['canned']): Promise<Fake> {
return new Promise((resolve) => {
const fake: Fake = { server: http.createServer(), url: '', calls: [], canned, down: false }
fake.server.on('request', (req, res) => {
if (fake.down) {
req.socket.destroy()
return
}
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
fake.calls.push({ path: apiPath, query: url.searchParams })
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ status: 'ok', response: fake.canned(apiPath, url.searchParams) }))
})
fake.server.listen(0, '127.0.0.1', () => {
fake.url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`
resolve(fake)
})
})
}
const stats = (queries: number) => ({
stats: { totalQueries: queries, totalBlocked: queries / 10 },
mainChartData: { labels: ['a', 'b'], datasets: [{ label: 'Total', data: [queries, queries] }] },
topDomains: [{ name: 'x.example', hits: queries }],
})
function logPage(prefix: string, stamps: string[], total: number) {
return {
pageNumber: 1,
totalPages: 1,
totalEntries: total,
entries: stamps.map((timestamp, i) => ({ rowNumber: i + 1, timestamp, qname: `${prefix}${i}.example` })),
}
}
function canned(queries: number, stamps: string[]): Fake['canned'] {
return (apiPath, query) => {
switch (apiPath) {
case 'dashboard/stats/get':
return stats(queries)
case 'dashboard/stats/getTop':
return { topDomains: [{ name: 'x.example', hits: queries }, { name: `only-${queries}.example`, hits: 1 }] }
case 'logs/query':
return logPage(`q${queries}-`, stamps.slice(0, Number(query.get('entriesPerPage') ?? 25)), stamps.length)
case 'apps/list':
return { apps: [{ name: 'Shared' }, { name: `App ${queries}` }] }
case 'settings/get':
return { enableBlocking: true, from: queries }
default:
return {}
}
}
}
let a: Fake
let b: Fake
let hub: http.Server
let base: string
let staticDir: string
function makeConfig(over: Record<string, string> = {}) {
return parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'token-a',
B_TOKEN: 'token-b',
HUB_USER: 'admin',
HUB_PASSWORD: 'secret',
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '15',
HUB_TIMEOUT_MS: '2000',
...over,
})
}
async function listen(server: http.Server): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
}
async function call(apiPath: string, params: Record<string, string> = {}, token?: string, method = 'GET') {
const query = new URLSearchParams(params)
const url = `${base}/api/${apiPath}${method === 'GET' && params ? `?${query}` : ''}`
const init: RequestInit = { method, headers: token ? { authorization: `Bearer ${token}` } : {} }
if (method === 'POST') {
init.body = query.toString()
init.headers = { ...init.headers, 'content-type': 'application/x-www-form-urlencoded' }
}
const res = await fetch(url, init)
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
async function login(): Promise<string> {
const { body } = await call('user/login', { user: 'admin', pass: 'secret', includeInfo: 'true' }, undefined, 'POST')
return body.token as string
}
beforeAll(async () => {
a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z']))
b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z']))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-'))
writeFileSync(path.join(staticDir, 'index.html'), '<html>root</html>')
mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true })
writeFileSync(path.join(staticDir, 'logs', 'index.html'), '<html>logs</html>')
mkdirSync(path.join(staticDir, 'assets'))
writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}')
})
afterAll(() => {
a.server.close()
b.server.close()
rmSync(staticDir, { recursive: true, force: true })
})
beforeEach(async () => {
a.down = false
b.down = false
hub?.close()
hub = createHub(makeConfig())
base = await listen(hub)
a.calls.length = 0
b.calls.length = 0
})
describe('config', () => {
it('refuses to start without credentials or a token', () => {
const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' }
expect(() => parseConfig(env)).toThrow(/HUB_USER/)
expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/)
})
it('derives the token variable from the id and applies defaults', () => {
const c = parseConfig({ HUB_SERVERS: 'my-dns.1=http://x/', MY_DNS_1_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' })
expect(c.servers).toEqual([{ id: 'my-dns.1', url: 'http://x', token: 't' }])
expect([c.port, c.cacheSeconds, c.timeoutMs, c.domain, c.staticDir]).toEqual([8080, 15, 8000, 'hub', './dist'])
})
})
describe('session', () => {
it('logs in and describes the servers as cluster nodes', async () => {
const token = await login()
expect(token).toMatch(/^[0-9a-f]{64}$/)
const { body } = await call('user/session/get', {}, token)
expect(body.status).toBe('ok')
expect(body.info.hub).toBe(true)
expect(body.info.clusterInitialized).toBe(true)
expect(body.info.clusterNodes).toEqual([{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }])
expect(body.info.permissions.Dashboard.canView).toBe(true)
expect(body.info.permissions.Logs.canView).toBe(true)
expect(body.info.permissions.Zones).toEqual({ canView: false, canModify: false, canDelete: false })
})
it('refuses a wrong password', async () => {
const { body } = await call('user/login', { user: 'admin', pass: 'nope' }, undefined, 'POST')
expect(body.status).toBe('error')
expect(body.token).toBeUndefined()
})
it('answers invalid-token for a missing, unknown or logged-out token', async () => {
expect((await call('dashboard/stats/get')).body.status).toBe('invalid-token')
expect((await call('dashboard/stats/get', {}, 'bogus')).body.status).toBe('invalid-token')
const token = await login()
await call('user/logout', {}, token)
expect((await call('user/session/get', {}, token)).body.status).toBe('invalid-token')
})
it('accepts the token as a parameter too', async () => {
const token = await login()
expect((await call('user/checkForUpdate', { token })).body.response.updateAvailable).toBe(false)
})
})
describe('read-only', () => {
it('refuses everything that is not a listed read', async () => {
const token = await login()
const { code, body } = await call('zones/delete', { zone: 'x' }, token, 'POST')
expect(code).toBe(403)
expect(body).toEqual({ status: 'error', errorMessage: 'The hub is read-only.' })
expect(a.calls).toHaveLength(0)
})
})
describe('single node', () => {
it('forwards to that server with its token and without node', async () => {
const token = await login()
const { body } = await call('dashboard/stats/get', { type: 'LastDay', node: 'b' }, token)
expect(body.response.stats.totalQueries).toBe(300)
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(1)
expect(b.calls[0].query.get('token')).toBe('token-b')
expect(b.calls[0].query.has('node')).toBe(false)
expect(b.calls[0].query.get('type')).toBe('LastDay')
expect(body.hubWarnings).toBeUndefined()
})
it('reports an unreachable server by its id', async () => {
const token = await login()
a.down = true
const { body } = await call('settings/get', { node: 'a' }, token)
expect(body.status).toBe('error')
expect(body.errorMessage).toMatch(/^a did not answer: /)
expect(body.errorMessage).not.toContain('token-a')
})
it('rejects an unknown node', async () => {
const token = await login()
expect((await call('settings/get', { node: 'zzz' }, token)).body.status).toBe('error')
})
})
describe('aggregate', () => {
it('sums the stats of every server', async () => {
const token = await login()
for (const node of [undefined, 'cluster']) {
const params: Record<string, string> = { type: 'LastDay' }
if (node) params.node = node
const { body } = await call('dashboard/stats/get', params, token)
expect(body.response.stats.totalQueries).toBe(400)
expect(body.response.mainChartData.datasets[0].data).toEqual([400, 400])
expect(body.hubWarnings).toEqual([])
}
})
it('merges the top lists by name', async () => {
const token = await login()
const { body } = await call('dashboard/stats/getTop', { type: 'LastDay', statsType: 'TopDomains', limit: '2' }, token)
expect(body.response.topDomains).toEqual([
{ name: 'x.example', hits: 400 },
{ name: 'only-100.example', hits: 1 },
])
})
it('merges query logs across servers with node tags and pages', async () => {
const token = await login()
const page1 = await call('logs/query', { pageNumber: '1', entriesPerPage: '3', descendingOrder: 'true' }, token)
expect(page1.body.response.totalEntries).toBe(4)
expect(page1.body.response.totalPages).toBe(2)
expect(page1.body.response.entries.map((e: any) => [e.node, e.timestamp])).toEqual([
['a', '2026-01-01T10:00:04Z'],
['b', '2026-01-01T10:00:03Z'],
['a', '2026-01-01T10:00:02Z'],
])
// Each server was asked for the head of the list that page N needs.
expect(a.calls[0].query.get('entriesPerPage')).toBe('3')
expect(a.calls[0].query.get('pageNumber')).toBe('1')
a.calls.length = 0
const page2 = await call('logs/query', { pageNumber: '2', entriesPerPage: '3', descendingOrder: 'true' }, token)
expect(a.calls[0].query.get('entriesPerPage')).toBe('6')
expect(page2.body.response.entries.map((e: any) => [e.node, e.rowNumber])).toEqual([['b', 4]])
const asc = await call('logs/query', { pageNumber: '1', entriesPerPage: '2', descendingOrder: 'false' }, token)
expect(asc.body.response.entries.map((e: any) => e.timestamp)).toEqual(['2026-01-01T10:00:01Z', '2026-01-01T10:00:02Z'])
})
it('unions the apps by name and asks the first server for other reads', async () => {
const token = await login()
const apps = await call('apps/list', {}, token)
expect(apps.body.response.apps.map((x: any) => x.name)).toEqual(['Shared', 'App 100', 'App 300'])
const settings = await call('settings/get', {}, token)
expect(settings.body.response.from).toBe(100)
expect(b.calls).toHaveLength(1)
})
it('serves a repeated call from the cache', async () => {
const token = await login()
await call('dashboard/stats/get', { type: 'LastDay' }, token)
await call('dashboard/stats/get', { type: 'LastDay' }, token)
expect(a.calls).toHaveLength(1)
await call('dashboard/stats/get', { type: 'LastWeek' }, token)
expect(a.calls).toHaveLength(2)
})
it('answers with the other server and a warning when one is down', async () => {
const token = await login()
b.down = true
const { body } = await call('dashboard/stats/get', { type: 'Warn' }, token)
expect(body.status).toBe('ok')
expect(body.response.stats.totalQueries).toBe(100)
expect(body.hubWarnings).toHaveLength(1)
expect(body.hubWarnings[0]).toMatch(/^b: /)
})
it('is an error when every server is down', async () => {
const token = await login()
a.down = true
b.down = true
const { body } = await call('dashboard/stats/get', { type: 'Dead' }, token)
expect(body.status).toBe('error')
expect(body.errorMessage).toMatch(/No server answered/)
})
})
describe('static files', () => {
it('serves files and the index of a folder, with the console headers on html', async () => {
const root = await fetch(`${base}/`)
expect(await root.text()).toBe('<html>root</html>')
expect(root.headers.get('content-security-policy')).toBe("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'")
expect(root.headers.get('x-content-type-options')).toBe('nosniff')
const logs = await fetch(`${base}/logs/`)
expect(await logs.text()).toBe('<html>logs</html>')
const js = await fetch(`${base}/assets/app.js`)
expect(js.headers.get('content-type')).toMatch(/^text\/javascript/)
})
it('redirects a folder without the slash and 404s the rest', async () => {
const redirect = await fetch(`${base}/logs`, { redirect: 'manual' })
expect(redirect.status).toBe(301)
expect(redirect.headers.get('location')).toBe('/logs/')
expect((await fetch(`${base}/nope.js`)).status).toBe(404)
// A folder with no index of its own is not a route.
expect((await fetch(`${base}/assets`, { redirect: 'manual' })).status).toBe(404)
expect((await fetch(`${base}/..%2f..%2fetc/passwd`)).status).toBeGreaterThanOrEqual(400)
})
it('answers the api under a sub path', async () => {
const res = await fetch(`${base}/dashboard/api/status`)
expect(((await res.json()) as { status: string }).status).toBe('ok')
})
})
+334
View File
@@ -0,0 +1,334 @@
/*
The hub: a small server that impersonates ONE Technitium DNS Server for the console and
answers from several real ones.
node --experimental-strip-types hub/server.ts
It serves the console's static build and a read-only, Technitium-compatible `/api/*`.
A call aimed at one server (`node=<id>`) is forwarded to it with that server's token; a
call with no node (or `node=cluster`) asks every server in parallel and merges the
answers (see merge.ts). Anything that would change data is refused.
*/
import http from 'node:http'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
import { Upstream, UpstreamError, type Params } from './upstream.ts'
import { Sessions } from './session.ts'
import {
mergeQueryLogs,
mergeSettled,
mergeStats,
mergeTopResponses,
type DashboardStats,
type QueryLogPage,
type Settled,
type TopEntry,
} from './merge.ts'
type Json = Record<string, unknown>
const MAX_BODY = 64 * 1024
/* Paths relative to `/api/`. Anything not listed here is refused with 403. */
const READ_ENDPOINTS = new Set([
'dashboard/stats/get',
'dashboard/stats/getTop',
'dashboard/metrics/json',
'apps/list',
'logs/query',
'logs/list',
'settings/get',
'user/checkForUpdate',
'admin/cluster/state',
])
const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json',
}
const HTML_HEADERS = {
'content-security-policy': "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'",
'x-content-type-options': 'nosniff',
}
function send(res: http.ServerResponse, code: number, body: unknown): void {
const text = JSON.stringify(body)
res.writeHead(code, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'content-length': Buffer.byteLength(text),
})
res.end(text)
}
const fail = (errorMessage: string): Json => ({ status: 'error', errorMessage })
async function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
if (req.method !== 'POST') return new URLSearchParams()
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
size += (chunk as Buffer).length
if (size > MAX_BODY) break
chunks.push(chunk as Buffer)
}
const type = String(req.headers['content-type'] ?? '')
if (size > MAX_BODY || !type.includes('application/x-www-form-urlencoded')) return new URLSearchParams()
return new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
}
function bearer(req: http.IncomingMessage): string | null {
const header = String(req.headers.authorization ?? '')
return header.startsWith('Bearer ') ? header.slice(7).trim() : null
}
function positive(value: string | undefined, fallback: number): number {
const n = Number(value)
return Number.isInteger(n) && n > 0 ? n : fallback
}
export function createHub(config: HubConfig): http.Server {
const upstream = new Upstream(config)
const sessions = new Sessions(config)
const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
async function everywhere<T>(apiPath: string, params: Params): Promise<Settled<T>[]> {
const results = await Promise.allSettled(config.servers.map((s) => upstream.call(s, apiPath, params)))
return results.map((r, i): Settled<T> => {
const node = config.servers[i].id
return r.status === 'fulfilled'
? { node, ok: true, data: r.value as T }
: { node, ok: false, error: r.reason instanceof Error ? r.reason.message : 'failed' }
})
}
/* `warnings` is given only by the aggregate answers (empty when every server answered), so the
console can tell "all back" from "not an aggregate" and clear its warning only on the former. */
function ok(response: unknown, warnings?: string[]): Json {
return warnings === undefined ? { status: 'ok', response } : { status: 'ok', response, hubWarnings: warnings }
}
async function single(server: HubServer, apiPath: string, params: Params): Promise<Json> {
try {
return ok(await upstream.call(server, apiPath, params))
} catch (e) {
if (e instanceof UpstreamError) {
return fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`)
}
throw e
}
}
async function aggregate(apiPath: string, params: Params): Promise<Json> {
try {
switch (apiPath) {
case 'dashboard/stats/get': {
const { data, warnings } = mergeSettled(await everywhere<DashboardStats>(apiPath, params))
return ok(mergeStats(data), warnings)
}
case 'dashboard/stats/getTop': {
const { data, warnings } = mergeSettled(await everywhere<Record<string, TopEntry[]>>(apiPath, params))
const limit = params.limit === undefined ? undefined : positive(params.limit, 1000)
return ok(mergeTopResponses(data, limit), warnings)
}
case 'logs/query': {
const pageNumber = positive(params.pageNumber, 1)
const entriesPerPage = positive(params.entriesPerPage, 25)
// Page N of the merged list is cut from the head of every server's list.
const asked = { ...params, pageNumber: '1', entriesPerPage: String(pageNumber * entriesPerPage) }
const settled = await everywhere<QueryLogPage>(apiPath, asked)
const { data, warnings } = mergeSettled(settled)
// Only the servers that answered, in server order, so the i-th page is the i-th survivor's.
const answered = settled.filter((r) => r.ok).map((r) => r.node)
const pages = data.map((page, i) => ({ node: answered[i], page }))
return ok(mergeQueryLogs(pages, { pageNumber, entriesPerPage, descending: params.descendingOrder !== 'false' }), warnings)
}
case 'apps/list': {
const { data, warnings } = mergeSettled(await everywhere<{ apps?: { name: string }[] }>(apiPath, params))
const seen = new Map<string, { name: string }>()
for (const r of data) for (const app of r.apps ?? []) if (!seen.has(app.name)) seen.set(app.name, app)
return ok({ ...data[0], apps: [...seen.values()] }, warnings)
}
default:
// Everything else read-only is answered by the first server.
return await single(config.servers[0], apiPath, params)
}
} catch (e) {
if (e instanceof Error && !(e instanceof UpstreamError)) return fail(e.message)
throw e
}
}
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
const form = await readForm(req)
const params: Params = {}
for (const [k, v] of url.searchParams) params[k] = v
for (const [k, v] of form) params[k] = v
// Public and session endpoints.
if (apiPath === 'status') {
send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false })
return
}
if (apiPath === 'user/login') {
const token = sessions.login(params.user ?? '', params.pass ?? '')
send(res, 200, token === null ? fail(`Invalid username or password for user: ${params.user ?? ''}`) : sessions.body(token))
return
}
const token = bearer(req) ?? params.token ?? null
if (!sessions.touch(token)) {
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
return
}
if (apiPath === 'user/session/get') {
send(res, 200, sessions.body(token as string))
return
}
if (apiPath === 'user/logout') {
sessions.logout(token as string)
send(res, 200, { status: 'ok' })
return
}
if (!READ_ENDPOINTS.has(apiPath)) {
send(res, 403, fail('The hub is read-only.'))
return
}
if (apiPath === 'user/checkForUpdate') {
send(res, 200, ok({ updateAvailable: false }))
return
}
if (apiPath === 'admin/cluster/state') {
send(
res,
200,
ok({
version: HUB_VERSION,
dnsServerDomain: config.domain,
clusterInitialized: true,
clusterDomain: config.domain,
clusterNodes: config.servers.map((s, i) => ({
id: i + 1,
name: s.id,
url: s.url,
ipAddresses: [],
type: i === 0 ? 'Primary' : 'Secondary',
state: 'Connected',
})),
}),
)
return
}
// What the real servers get: the caller's params minus the hub's own.
const { node = '', token: _token, ...forward } = params
const server = byId.get(node)
if (server !== undefined) {
send(res, 200, await single(server, apiPath, forward))
return
}
if (node !== '' && node !== 'cluster') {
send(res, 200, fail(`Unknown node: ${node}`))
return
}
send(res, 200, await aggregate(apiPath, forward))
}
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { allow: 'GET, HEAD' }).end()
return
}
const root = path.resolve(config.staticDir)
let pathname: string
try {
pathname = decodeURIComponent(url.pathname)
} catch {
res.writeHead(400).end()
return
}
const target = path.resolve(root, '.' + pathname)
if (pathname.includes('\0') || (target !== root && !target.startsWith(root + path.sep))) {
res.writeHead(403).end()
return
}
let file = target
let info = await stat(target).catch(() => null)
if (info?.isDirectory()) {
const index = path.join(target, 'index.html')
if ((await stat(index).catch(() => null))?.isFile() !== true) info = null
else if (!pathname.endsWith('/')) {
// As Technitium: `/x` -> `/x/` when the folder has an index.
res.writeHead(301, { location: `${url.pathname}/${url.search}` }).end()
return
} else {
file = index
info = await stat(index)
}
}
if (info === null || !info.isFile()) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Not found')
return
}
const ext = path.extname(file).toLowerCase()
const headers: Record<string, string | number> = {
'content-type': TYPES[ext] ?? 'application/octet-stream',
'content-length': info.size,
}
if (ext === '.html') Object.assign(headers, HTML_HEADERS, { 'cache-control': 'no-cache' })
res.writeHead(200, headers)
if (req.method === 'HEAD') {
res.end()
return
}
createReadStream(file).on('error', () => res.destroy()).pipe(res)
}
return http.createServer((req, res) => {
void (async () => {
try {
const url = new URL(req.url ?? '/', 'http://hub')
// The console may sit at a sub path: any `<prefix>/api/<path>` is the API.
const match = /^(?:.*\/)?api\/(.+?)\/?$/.exec(url.pathname)
if (match !== null) await api(req, res, match[1], url)
else await serveStatic(req, res, url)
} catch (e) {
console.error('hub error:', e instanceof Error ? e.message : e)
if (!res.headersSent) send(res, 500, fail('Internal hub error.'))
else res.destroy()
}
})()
})
}
/* Run directly (not imported by the tests). */
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
let config: HubConfig
try {
config = loadConfig()
} catch (e) {
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
createHub(config).listen(config.port, () => {
console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`)
})
}
+111
View File
@@ -0,0 +1,111 @@
/*
The hub's own sign-in. One user from the environment, sessions kept in memory with an
idle expiry, and the `info` object the console reads from `user/login` and
`user/session/get` (flat, as the real server sends it).
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
export const IDLE_MS = 30 * 60_000
/* Compared as hashes so the lengths always match and the time does not depend on the content. */
function sameSecret(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest()
const hb = createHash('sha256').update(b).digest()
return timingSafeEqual(ha, hb)
}
/*
Every key `src/app/sections.ts` reads, spelled out: the console hides a section only
when `canView === false`, so a missing key would leave it visible. The hub shows the
Dashboard and the Logs; About has no permission and is always there. The hub is
read-only, so nothing can be modified or deleted.
*/
const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
]
const VIEWABLE = new Set(['Dashboard', 'Logs'])
export class Sessions {
#config: HubConfig
#now: () => number
#started: string
#seen = new Map<string, number>()
constructor(config: HubConfig, now: () => number = Date.now) {
this.#config = config
this.#now = now
this.#started = new Date(now()).toISOString()
}
/** A new session token, or null when the credentials are wrong. */
login(user: string, password: string): string | null {
// Both are always compared, so a wrong user and a wrong password cost the same.
const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase())
const passOk = sameSecret(password, this.#config.password)
if (!(userOk && passOk)) return null
this.#sweep()
const token = randomBytes(32).toString('hex')
this.#seen.set(token, this.#now())
return token
}
/** True while the token is valid; each use extends it. */
touch(token: string | null | undefined): boolean {
if (!token) return false
const last = this.#seen.get(token)
if (last === undefined) return false
if (this.#now() - last > IDLE_MS) {
this.#seen.delete(token)
return false
}
this.#seen.set(token, this.#now())
return true
}
logout(token: string): void {
this.#seen.delete(token)
}
#sweep(): void {
const now = this.#now()
for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token)
}
info() {
const { servers, domain } = this.#config
return {
version: HUB_VERSION,
uptimestamp: this.#started,
dnsServerDomain: domain,
permissions: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, { canView: VIEWABLE.has(key), canModify: false, canDelete: false }]),
),
/* The console offers its node selector when the cluster is initialised. The
first server plays the Primary (`primaryNodeName` looks for one). */
clusterInitialized: true,
clusterDomain: domain,
clusterNodes: servers.map((s, i) => ({ name: s.id, type: i === 0 ? 'Primary' : 'Secondary' })),
defaultRecordTtl: 3600,
defaultNsRecordTtl: 3600,
defaultSoaRecordTtl: 900,
useSoaSerialDateScheme: true,
dnssecValidation: true,
hub: true,
}
}
/** The flat body of `user/login` and `user/session/get`. */
body(token: string) {
return {
status: 'ok',
token,
username: this.#config.user,
displayName: this.#config.user,
type: 'Local',
isSsoUser: false,
totpEnabled: false,
info: this.info(),
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "../node_modules/.tmp/tsconfig.hub.tsbuildinfo",
"target": "esnext",
"lib": ["ESNext"],
"types": ["node"],
"skipLibCheck": true,
/* The hub runs under `node --experimental-strip-types`. */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"erasableSyntaxOnly": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"strict": true
},
"include": ["*.ts"]
}
+97
View File
@@ -0,0 +1,97 @@
/*
Calls to the real Technitium servers: `GET <url>/api/<path>?<params>&token=<token>`.
Successful answers are cached for a few seconds, so a dashboard that refreshes every
minute (and several people looking at it) does not load the servers.
*/
import type { HubConfig, HubServer } from './config.ts'
/*
`unreachable`: no answer (network, timeout, not JSON). `rejected`: the server answered
with a status other than `ok`. The hub never relays the server's `invalid-token`, or
the console would sign the hub's own user out because a hub token expired.
*/
export class UpstreamError extends Error {
kind: 'unreachable' | 'rejected'
constructor(kind: 'unreachable' | 'rejected', message: string) {
super(message)
this.name = 'UpstreamError'
this.kind = kind
}
}
export type Params = Record<string, string>
interface Cached {
until: number
response: unknown
}
const MAX_CACHED = 500
export class Upstream {
#config: HubConfig
#fetch: typeof fetch
#cache = new Map<string, Cached>()
#now: () => number
constructor(config: HubConfig, fetchImpl: typeof fetch = fetch, now: () => number = Date.now) {
this.#config = config
this.#fetch = fetchImpl
this.#now = now
}
/** The `response` of the server's envelope, or an `UpstreamError`. */
async call(server: HubServer, path: string, params: Params = {}): Promise<unknown> {
const sorted = Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
const key = `${server.id}\n${path}\n${new URLSearchParams(sorted).toString()}`
const hit = this.#cache.get(key)
if (hit !== undefined && hit.until > this.#now()) return hit.response
const query = new URLSearchParams(sorted)
query.set('token', server.token)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs)
let envelope: { status?: string; response?: unknown; errorMessage?: string }
try {
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
signal: controller.signal,
headers: { accept: 'application/json' },
})
envelope = (await res.json()) as typeof envelope
} catch (e) {
throw new UpstreamError('unreachable', this.#describe(e, server))
} finally {
clearTimeout(timer)
}
if (envelope?.status !== 'ok') {
const why =
envelope?.status === 'invalid-token'
? 'its API token was refused'
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
throw new UpstreamError('rejected', why)
}
if (this.#config.cacheSeconds > 0) {
if (this.#cache.size >= MAX_CACHED) this.#prune()
this.#cache.set(key, { until: this.#now() + this.#config.cacheSeconds * 1000, response: envelope.response })
}
return envelope.response
}
#prune(): void {
const now = this.#now()
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
// Still full of live entries: drop the oldest inserted.
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
}
/* A short reason without anything that could carry the token. */
#describe(e: unknown, server: HubServer): string {
if (e instanceof Error && e.name === 'AbortError') return `timed out after ${this.#config.timeoutMs} ms`
const cause = e instanceof Error ? (e.cause as { code?: unknown } | undefined) : undefined
const text = typeof cause?.code === 'string' ? cause.code : e instanceof Error ? e.message : 'request failed'
return text.split(server.token).join('***')
}
}
+1
View File
@@ -9,6 +9,7 @@
"scripts": {
"dev": "vite",
"mock": "node dev/mock-api.mjs",
"hub": "node --experimental-strip-types hub/server.ts",
"build": "tsc -b && vite build",
"lint": "oxlint",
"lint:language": "node dev/check-language.mjs",
+5
View File
@@ -1,4 +1,5 @@
import { urlApi } from '../app/base'
import { setHubWarnings } from './hub-warnings'
/*
The console's HTTP client. A replica of the old console's `HTTPRequest` helper
(upstream/master:DnsServerCore/www/js/common.js:28).
@@ -66,6 +67,8 @@ export interface ApiOptions {
interface Envelope {
status?: string
errorMessage?: string
/** Only from the hub: servers that did not answer an aggregate call. */
hubWarnings?: unknown
}
/*
@@ -101,6 +104,8 @@ cannot go through it because their good answer is not JSON (`allowed/export`,
export function envelopeOutcome<T>(payload: Envelope, token?: string | null): ApiOutcome<T> {
switch (payload.status) {
case 'ok':
// Only an aggregate answer carries the field; a per-node call must not clear it.
if (payload.hubWarnings !== undefined) setHubWarnings(payload.hubWarnings)
return { kind: 'ok', data: payload as T }
case 'invalid-token':
onExpired?.(token)
+34
View File
@@ -0,0 +1,34 @@
import { useSyncExternalStore } from 'react'
/*
What the hub says about the servers behind it. On an aggregate call it adds a
top-level `hubWarnings: string[]` to the envelope when a server did not answer
("ndns: timed out after 8000 ms"). A server never sends it, so the list stays empty
outside the hub.
The envelope is read in one place (`client.ts`), far from the header that shows
this, so the latest list lives here and the header subscribes. Every ok response
sets it: an answer without warnings clears it.
*/
let current: readonly string[] = []
const listeners = new Set<() => void>()
export function setHubWarnings(next: unknown): void {
const list = Array.isArray(next) ? next.filter((w): w is string => typeof w === 'string') : []
if (list.length === current.length && list.every((w, i) => w === current[i])) return
current = list
for (const fn of listeners) fn()
}
export function getHubWarnings(): readonly string[] {
return current
}
export function subscribeHubWarnings(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
export function useHubWarnings(): readonly string[] {
return useSyncExternalStore(subscribeHubWarnings, getHubWarnings, getHubWarnings)
}
+2
View File
@@ -60,6 +60,8 @@ export interface QueryLogEntry {
qtype: string | null
qclass: string | null
answer: string | null
/** Only from the hub: the server this merged row came from. */
node?: string
}
export interface QueryLogPage {
+131
View File
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Shell, type ShellSession } from './Shell'
import { ThemeProvider } from '../theme/ThemeProvider'
import * as client from '../api/client'
import { setHubWarnings } from '../api/hub-warnings'
import { forgetRoot } from './route'
/*
The console behind a hub (`info.hub`): no Change Password at login, no account entries
the hub cannot serve, and a header that says when a server did not answer.
*/
function session(hub: boolean): ShellSession {
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
return {
token: 'tok',
displayName: 'Administrator',
username: 'admin',
info: {
version: '1.0.0',
uptimestamp: '2026-10-15T08:00:00Z',
dnsServerDomain: 'hub',
...(hub ? { hub: true } : {}),
permissions: Object.fromEntries(all.map((s) => [s, { canView: s === 'Dashboard', canModify: false, canDelete: false }])),
},
}
}
function mount(hub: boolean, forcePasswordChange = false) {
return render(
<ThemeProvider>
<Shell session={session(hub)} onLogout={() => {}} forcePasswordChange={forcePasswordChange} />
</ThemeProvider>,
)
}
beforeEach(() => {
localStorage.clear()
forgetRoot()
setHubWarnings([])
window.history.replaceState(null, '', '/about/')
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
})
afterEach(() => vi.restoreAllMocks())
describe('Shell on a hub', () => {
it('does not open Change Password after a factory-credentials login', () => {
mount(true, true)
expect(screen.queryByRole('dialog', { name: /change password/i })).not.toBeInTheDocument()
})
it('still opens it on a server', async () => {
mount(false, true)
expect(await screen.findByRole('dialog', { name: /change password/i })).toBeInTheDocument()
})
it('leaves out the account entries the hub cannot serve', async () => {
mount(true)
await userEvent.click(screen.getByRole('button', { name: /Administrator/ }))
for (const name of ['My Profile', 'Change Password', 'Configure 2FA', 'Create API Token']) {
expect(screen.queryByRole('menuitem', { name })).not.toBeInTheDocument()
}
expect(screen.getByRole('menuitem', { name: 'Change Theme' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Logout' })).toBeInTheDocument()
})
it('keeps them on a server', async () => {
mount(false)
await userEvent.click(screen.getByRole('button', { name: /Administrator/ }))
expect(screen.getByRole('menuitem', { name: 'My Profile' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Create API Token' })).toBeInTheDocument()
})
it('the header shows the servers that did not answer, read from an api response', async () => {
mount(true)
expect(screen.getByRole('button', { name: 'Server status' })).toHaveAttribute('data-attention', 'false')
// An aggregate response carries the warning; `apiRequest` reads it in one place.
vi.restoreAllMocks()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 'ok', response: {}, hubWarnings: ['ndns: timed out after 8000 ms'] })),
)
await act(async () => {
await client.apiRequest('dashboard/stats/get', { token: 'tok' })
})
const status = screen.getByRole('button', { name: 'Server status' })
expect(status).toHaveTextContent('1 server not answering')
expect(status).toHaveAttribute('data-attention', 'true')
await userEvent.click(status)
expect(screen.getByText('ndns: timed out after 8000 ms')).toBeInTheDocument()
await act(async () => {
setHubWarnings(['a: x', 'b: y'])
})
expect(status).toHaveTextContent('2 servers not answering')
// The next answer without warnings clears it.
await act(async () => {
setHubWarnings([])
})
expect(status).not.toHaveTextContent('not answering')
})
/* Per-node calls (live metrics, settings) arrive right after an aggregate and say nothing. */
it('an ok response without the field leaves the warning, and an empty list clears it', async () => {
mount(true)
vi.restoreAllMocks()
const answer = (body: object) =>
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ status: 'ok', response: {}, ...body })))
answer({ hubWarnings: ['ndns: ECONNREFUSED'] })
await act(async () => {
await client.apiRequest('dashboard/stats/get', { token: 'tok' })
})
const status = screen.getByRole('button', { name: 'Server status' })
expect(status).toHaveTextContent('1 server not answering')
answer({})
await act(async () => {
await client.apiRequest('settings/get', { token: 'tok', node: 'dns' })
})
expect(status).toHaveTextContent('1 server not answering')
answer({ hubWarnings: [] })
await act(async () => {
await client.apiRequest('dashboard/stats/get', { token: 'tok' })
})
expect(status).not.toHaveTextContent('not answering')
})
})
+2
View File
@@ -233,6 +233,8 @@ star, and the header is always there: toggle, breadcrumbs and the server's statu
.pdot { width: 8px; height: 8px; border-radius: 99px; background: var(--ok, currentColor); }
.pdot[data-tone='warn'], .pdot[data-tone='warning'] { background: var(--warn, currentColor); }
.pdot[data-tone='bad'], .pdot[data-tone='danger'] { background: var(--bad, currentColor); }
.warnings { margin: var(--s-6) 0 0; padding: 0; list-style: none; display: grid; gap: var(--s-3); font: 400 var(--t-3)/1.3 var(--font-mono); color: var(--ink); }
.warnings li::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: var(--s-4); border-radius: 99px; background: var(--warn); }
.pop dl { display: grid; grid-template-columns: auto 1fr; gap: var(--s-4) var(--s-8); margin: 0; font-size: var(--t-3); }
.pop dt { color: var(--mute); }
.pop dd { margin: 0; text-align: right; font-family: var(--font-mono); }
+36 -15
View File
@@ -31,11 +31,12 @@ import { publicUrl } from './base'
import { Menu } from '../ui/Menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { ThisServerName } from '../ui/ClusterNodeSelect'
import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
import { BlockingMark } from './BlockingMark'
import { useBlockingMark } from '../screens/blocking/blocking-state'
import { useHubWarnings } from '../api/hub-warnings'
import { Confirm } from '../ui/Confirm'
import { Notifier } from '../ui/Notifier'
import type { Notice } from '../lib/notice'
@@ -102,6 +103,8 @@ export interface ShellSession {
dnsServerDomain: string
permissions?: Record<string, Permission>
clusterInitialized?: boolean
/** Only on the hub (`hub/`): a read-only console over several servers. */
hub?: boolean
/** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */
clusterDomain?: string
clusterNodes?: { name: string; type: string }[]
@@ -125,6 +128,8 @@ export function Shell({
forcePasswordChange?: boolean
}) {
const permissions = session.info?.permissions
const hub = session.info?.hub === true
const hubWarnings = useHubWarnings()
// Memoised: if it is recreated on every render, the `hashchange` resubscribes on each.
const sections = useMemo(() => visibleSections(permissions), [permissions])
/* The starting section comes from the address bar if it carries one, and only
@@ -166,11 +171,11 @@ export function Shell({
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange && !hub ? 'password' : null)
/* The factory password travels only with the opening the login caused; from the
user menu `showChangePasswordModal()` is called with nothing (main.js). */
const [forcedCurrentPassword, setForcedCurrentPassword] = useState<string | undefined>(
forcePasswordChange ? 'admin' : undefined,
forcePasswordChange && !hub ? 'admin' : undefined,
)
const [sub, setSub] = useState<string | null>(initialRoute?.sub ?? null)
const [displayName, setDisplayName] = useState(session.displayName)
@@ -398,6 +403,7 @@ export function Shell({
return (
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<HubMode.Provider value={hub}>
<SlotProvider>
{(slot) => (
<div className={styles.shell} data-rail={rail}>
@@ -499,26 +505,31 @@ export function Shell({
>
{(close) => (
<>
<button type="button" onClick={() => { close(); open('profile') }}>
My Profile
</button>
{/* The hub serves reads only: no profile, password, 2FA or tokens. */}
{!hub && (
<button type="button" onClick={() => { close(); open('profile') }}>
My Profile
</button>
)}
{/* main.js:71-87 (v15.5.1) switches on `sessionData.type`:
`RemoteSSO` hides both, `RemoteLDAP` hides only the password
(the directory owns it, the 2FA is still this server's) and
`Local` or anything else shows both. */}
{session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
{!hub && session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
<button type="button" onClick={() => { close(); open('password') }}>
Change Password
</button>
)}
{session.type !== 'RemoteSSO' && (
{!hub && session.type !== 'RemoteSSO' && (
<button type="button" onClick={() => { close(); open('twofa') }}>
Configure 2FA
</button>
)}
<button type="button" onClick={() => { close(); open('token') }}>
Create API Token
</button>
{!hub && (
<button type="button" onClick={() => { close(); open('token') }}>
Create API Token
</button>
)}
{/* index.html:71: after the account's own entries, before the
update notification. */}
<button type="button" onClick={() => { close(); open('theme') }}>
@@ -574,9 +585,13 @@ export function Shell({
))}
</div>
<Popover>
<PopoverTrigger className={styles.status} data-attention={blockingNow != null} aria-label="Server status">
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
<span className={styles.lbl}>{blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')}</span>
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
<span className={styles.lbl}>
{hubWarnings.length > 0
? `${hubWarnings.length} ${hubWarnings.length === 1 ? 'server' : 'servers'} not answering`
: blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')}
</span>
</PopoverTrigger>
<PopoverContent align="end" className={styles.pop}>
<dl>
@@ -585,6 +600,11 @@ export function Shell({
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
</dl>
{hubWarnings.length > 0 && (
<ul className={styles.warnings} aria-label="Servers not answering">
{hubWarnings.map((w) => (<li key={w}>{w}</li>))}
</ul>
)}
<Versions
token={session.token}
serverVersion={info?.version}
@@ -737,7 +757,7 @@ export function Shell({
])}
</CommandGroup>
<CommandGroup heading="Account">
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
{!hub && <CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>}
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
</CommandGroup>
@@ -798,6 +818,7 @@ export function Shell({
</div>
)}
</SlotProvider>
</HubMode.Provider>
</ThisServerName.Provider>
</HandoffContext.Provider>
)
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Logs } from './Logs'
import * as logs from '../../api/logs'
import * as apps from '../../api/apps'
import type { QueryLogEntry } from '../../api/logs'
import { HandoffContext } from '../../app/handoff'
/* The hub tags the rows it merged with the server they came from. */
afterEach(() => vi.restoreAllMocks())
function entry(rowNumber: number, extra: Partial<QueryLogEntry> = {}): QueryLogEntry {
return {
rowNumber,
timestamp: '2026-08-26T05:32:14Z',
clientIpAddress: '10.0.0.7',
protocol: 'Udp',
responseType: 'Recursive',
rcode: 'NoError',
qname: `r${rowNumber}.example`,
qtype: 'A',
qclass: 'IN',
answer: null,
...extra,
}
}
async function show(entries: QueryLogEntry[]) {
vi.spyOn(apps, 'listApps').mockResolvedValue({
kind: 'ok',
data: {
status: 'ok',
response: {
apps: [
{
name: 'Query Logs (Sqlite)',
description: '',
version: '8.0',
dnsApps: [
{
classPath: 'QueryLogsSqlite.App',
description: '',
recordDataTemplate: null,
isAppRecordRequestHandler: false,
isRequestController: false,
isAuthoritativeRequestHandler: false,
isRequestBlockingHandler: false,
isQueryLogger: true,
isQueryLogs: true,
isPostProcessor: false,
},
],
},
],
},
},
} as never)
vi.spyOn(logs, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: entries.length, entries } },
} as never)
render(
<HandoffContext.Provider value={{ queryDnsServer: vi.fn(), showQueryLogs: vi.fn() }}>
<Logs token="t" sub="Query Logs" />
</HandoffContext.Provider>,
)
await userEvent.click(await screen.findByRole('button', { name: 'Query' }))
await screen.findByText('r1.example')
}
describe('Query Logs: the Server column', () => {
it('appears, after Timestamp, when rows carry a node', async () => {
await show([entry(1, { node: 'dns' }), entry(2, { node: 'ndns' })])
const headers = screen.getAllByRole('columnheader').map((h) => h.textContent)
expect(headers.indexOf('Server')).toBe(headers.indexOf('Timestamp') + 1)
expect(screen.getByText('dns')).toBeInTheDocument()
expect(screen.getByText('ndns')).toBeInTheDocument()
})
it('is absent when no row has one', async () => {
await show([entry(1), entry(2)])
expect(screen.queryByRole('columnheader', { name: 'Server' })).not.toBeInTheDocument()
})
})
+9
View File
@@ -948,6 +948,15 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
td: () => ({ className: `${styles.mono} ${styles.stamp}` }),
cell: (e) => (<Stamp text={dateTime(e.timestamp)} />),
},
/* Only the hub tags rows with the server they came from. */
...(page.entries.some((e) => e.node != null)
? [{
id: 'cNode',
header: 'Server',
td: () => ({ className: styles.mono }),
cell: (e: QueryLogEntry) => e.node ?? '',
}]
: []),
{
id: 'c2',
header: 'Client IP Address',
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ClusterNodeSelect, HubMode, nodeOptions, AGGREGATE } from './ClusterNodeSelect'
const NODES = [
{ name: 'dns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
/* Behind a hub the nodes are independent servers, not a cluster. */
describe('hub mode', () => {
it('names the aggregate "All servers" and lists servers without a type', () => {
expect(nodeOptions(NODES, { aggregate: true, hub: true })).toEqual([
{ value: AGGREGATE, label: 'All servers' },
{ value: 'dns', label: 'dns' },
{ value: 'ndns', label: 'ndns' },
])
})
it('shows All servers and labels the selector Server, whatever label the screen asks for', () => {
render(
<HubMode.Provider value>
<ClusterNodeSelect nodes={NODES} initialised aggregate value={AGGREGATE} onChange={() => {}} label="Cluster Node" />
</HubMode.Provider>,
)
expect(screen.getByText('All servers')).toBeInTheDocument()
expect(screen.getByText('Server')).toBeInTheDocument()
expect(screen.queryByText('Cluster Node')).not.toBeInTheDocument()
})
it('keeps the cluster wording outside the hub', () => {
render(<ClusterNodeSelect nodes={NODES} initialised aggregate value={AGGREGATE} onChange={() => {}} label="Cluster Node" />)
expect(screen.getByText('Cluster')).toBeInTheDocument()
expect(screen.getByText('Cluster Node')).toBeInTheDocument()
})
})
+13 -5
View File
@@ -11,6 +11,13 @@ instead of the empty-value dash.
*/
export const ThisServerName = createContext('')
/*
True when the console talks to a hub (`info.hub` in the session) and not to a
Technitium server. The "nodes" are then independent servers, so the words change:
"All servers" for the aggregate, "Server" for the selector, and no `(primary)` tag.
*/
export const HubMode = createContext(false)
/*
Which cluster node a screen is looking at.
@@ -39,10 +46,10 @@ export const AGGREGATE = 'cluster'
export function nodeOptions(
nodes: ClusterNode[],
{ aggregate = false }: { aggregate?: boolean } = {},
{ aggregate = false, hub = false }: { aggregate?: boolean; hub?: boolean } = {},
): { value: string; label: string }[] {
const out = aggregate ? [{ value: AGGREGATE, label: 'Cluster' }] : []
for (const n of nodes) out.push({ value: n.name, label: `${n.name} (${n.type.toLowerCase()})` })
const out = aggregate ? [{ value: AGGREGATE, label: hub ? 'All servers' : 'Cluster' }] : []
for (const n of nodes) out.push({ value: n.name, label: hub ? n.name : `${n.name} (${n.type.toLowerCase()})` })
return out
}
@@ -71,6 +78,7 @@ export function ClusterNodeSelect({
disabled?: boolean
}) {
const thisServer = useContext(ThisServerName)
const hub = useContext(HubMode)
if (!initialised) return null
/* Only this server's own name stands in for the empty value: it is the node the
@@ -79,10 +87,10 @@ export function ClusterNodeSelect({
const shown = value === '' && !aggregate && nodes.some((n) => n.name === thisServer) ? thisServer : value
return (
<Field label={label}>
<Field label={hub ? 'Server' : label}>
{(id) => (
<Select id={id} value={shown} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
{nodeOptions(nodes, { aggregate }).map((o) => (
{nodeOptions(nodes, { aggregate, hub }).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
+2 -1
View File
@@ -4,6 +4,7 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./update-check/tsconfig.json" }
{ "path": "./update-check/tsconfig.json" },
{ "path": "./hub/tsconfig.json" }
]
}
+4 -1
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { defineConfig } from 'vitest/config'
import { configDefaults, defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
/* The build injects this from package.json (see vite.config.ts). The tests run
@@ -19,5 +19,8 @@ export default defineConfig({
menus in a row took more than the default 5 s there while taking about one here. */
testTimeout: 20000,
hookTimeout: 20000,
/* Git worktrees of other sessions live under `.claude/`: their copies of the
tests are not this checkout's. */
exclude: [...configDefaults.exclude, '.claude/**'],
},
})