From 3f917c6bd273cafb49a40464d34d0e9d43f112a8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 30 Oct 2026 19:21:59 +0700 Subject: [PATCH] Hub stage 4: its own image and pipeline docker/Dockerfile.hub builds the console and ships it with the hub sources on node:22-alpine, without node_modules, as a non-root user with a healthcheck. .gitea/workflows/hub.yaml checks and pushes git.shx.one/denozord/technitium-hub (latest from main, sha-*, X.Y.Z and X.Y from hub-v tags), independent of the console pipeline, which now ignores hub-only changes. Compose example for hub.shnt.top and hub/README.md, with the read-only permissions each server's token needs. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yaml | 7 +++ .gitea/workflows/hub.yaml | 81 +++++++++++++++++++++++++++++++++++ docker/Dockerfile.hub | 31 ++++++++++++++ docs/compose.hub.example.yaml | 48 +++++++++++++++++++++ hub/README.md | 63 +++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 .gitea/workflows/hub.yaml create mode 100644 docker/Dockerfile.hub create mode 100644 docs/compose.hub.example.yaml create mode 100644 hub/README.md diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ae53ac5..22b325f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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: diff --git a/.gitea/workflows/hub.yaml b/.gitea/workflows/hub.yaml new file mode 100644 index 0000000..5773ad1 --- /dev/null +++ b/.gitea/workflows/hub.yaml @@ -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 diff --git a/docker/Dockerfile.hub b/docker/Dockerfile.hub new file mode 100644 index 0000000..83ef8e5 --- /dev/null +++ b/docker/Dockerfile.hub @@ -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 ES 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"] diff --git a/docs/compose.hub.example.yaml b/docs/compose.hub.example.yaml new file mode 100644 index 0000000..cb07793 --- /dev/null +++ b/docs/compose.hub.example.yaml @@ -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): +# DNS_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=адрес, по порядку; токен каждого берётся из _TOKEN + - HUB_SERVERS=dns=https://dns.shnt.top,ndns=https://ndns.shnt.top + - DNS_TOKEN=${DNS_TOKEN} + - NDNS_TOKEN=${NDNS_TOKEN} + - HUB_USER=${HUB_USER} + - HUB_PASSWORD=${HUB_PASSWORD} + - HUB_DOMAIN=hub.shnt.top + - TZ=Asia/Novosibirsk + labels: + - "traefik.enable=true" + - "traefik.docker.network=dns-net" + + - "traefik.http.routers.hub.rule=Host(`hub.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 diff --git a/hub/README.md b/hub/README.md new file mode 100644 index 0000000..f79438f --- /dev/null +++ b/hub/README.md @@ -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=` 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. | +| `_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`.