Compare commits
7
Commits
37a74199e9
...
042cb116ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
042cb116ea | ||
|
|
607d46f872 | ||
|
|
a86d1a088a | ||
|
|
3f917c6bd2 | ||
|
|
48b5d9fb15 | ||
|
|
5f29a438c9 | ||
|
|
9df2c2b908 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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})`))
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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('***')
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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/**'],
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user