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 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-30 19:21:59 +07:00
1 parent 48b5d9fb15
commit 3f917c6bd2
5 files changed
+230

No files matched your search

+7
View File
@@ -4,6 +4,13 @@ on:
push: push:
branches: [main] branches: [main]
tags: ['v*'] 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: pull_request:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
+81
View File
@@ -0,0 +1,81 @@
# The hub has its own pipeline and its own image (git.shx.one/denozord/technitium-hub),
# independent of ci.yaml. A change that only touches the console's docs does not run it.
name: Hub
on:
push:
branches: [main]
tags: ['hub-v*']
paths:
- 'hub/**'
- 'src/**'
- 'public/**'
- 'index.html'
- 'package.json'
- 'package-lock.json'
- 'vite.config.ts'
- 'docker/Dockerfile.hub'
- '.gitea/workflows/hub.yaml'
workflow_dispatch:
concurrency:
group: hub-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npx vitest run hub
image:
needs: check
if: gitea.ref_name == 'main' || startsWith(gitea.ref, 'refs/tags/hub-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTION_PAT || secrets.ACTIONS_PAT }}
# The hub is not named after the repository: its own image. Registry names are lowercase.
- name: Resolve image name
id: image
run: |
owner="$(dirname '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')"
echo "name=git.shx.one/${owner}/technitium-hub" >> "$GITHUB_OUTPUT"
# A hub-v1.2.3 tag gives 1.2.3 and 1.2 (the prefix is stripped); main gives latest.
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=match,pattern=hub-v(\d+\.\d+\.\d+),group=1
type=match,pattern=hub-v(\d+\.\d+)\.\d+,group=1
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
type=sha,prefix=sha-
- uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.hub
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.image.outputs.name }}:buildcache
cache-to: type=registry,ref=${{ steps.image.outputs.name }}:buildcache,mode=max
+31
View File
@@ -0,0 +1,31 @@
# The hub image built by Gitea CI (git.shx.one): one small container that serves the
# console and a read-only, Technitium-compatible API over several real servers.
# See hub/README.md. Self-contained: it does not use the console image.
#
# docker run --rm -p 8080:8080 -e HUB_SERVERS=dns=https://dns.example.net \
# -e DNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... \
# git.shx.one/denozord/technitium-hub:latest
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# What the hub runs: its sources without the tests, and a package.json that makes the
# .ts files 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"]
+48
View File
@@ -0,0 +1,48 @@
version: "3.8"
# Хаб: одна консоль со статистикой нескольких серверов Technitium (см. hub/README.md).
# Подключается к уже работающему Traefik из compose.shnt.example.yaml (сеть dns-net).
#
# На КАЖДОМ сервере Technitium, один раз:
# 1. Administration > Groups: группа hub-readers, ей право только View на Dashboard, Logs,
# Apps (список приложений логирования для Query Logs) и Settings (состояние блокировки).
# 2. Administration > Users: пользователь hub-reader в этой группе.
# 3. Войти под ним и создать токен: Administration > Sessions > Create API Token.
# Токены кладутся в .env рядом с этим файлом (не в git):
# 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=адрес, по порядку; токен каждого берётся из <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
+63
View File
@@ -0,0 +1,63 @@
# Technitium hub
A small, dependency-free Node program that looks like ONE Technitium DNS Server to the
console and answers from several real ones. It serves the console's static build and a
read-only, Technitium-compatible `/api/*`:
- `node=<id>` forwards the call to that server, with that server's token.
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers
(statistics are summed, top lists and query logs are merged). If a server does not
answer, the others are shown and the response carries `hubWarnings`.
- Anything that would change data is refused with `403`. The hub has its own sign-in.
The servers themselves are not changed: each only needs a read-only API token. Create it
for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
apps Query Logs reads from) and Settings (the blocking state).
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `HUB_SERVERS` | required | Ordered `id=url` list, e.g. `dns=https://dns.example.net,ndns=https://ndns.example.net`. The first server is shown as the Primary node. |
| `<ID>_TOKEN` | required | API token of each server; the id is uppercased with non-alphanumerics as `_` (`dns` gives `DNS_TOKEN`, `my-dns` gives `MY_DNS_TOKEN`). |
| `HUB_USER`, `HUB_PASSWORD` | required | The hub's own sign-in. It refuses to start without them. |
| `HUB_PORT` | `8080` | Listening port. |
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
| `HUB_STATIC` | `./dist` | The console's build to serve. |
## Endpoints
Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`,
`user/checkForUpdate` (always "no update") and these reads:
`dashboard/stats/get`, `dashboard/stats/getTop`, `dashboard/metrics/json`, `apps/list`,
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized).
Everything else answers `403 The hub is read-only.`
## Run locally
```sh
npm run build
node dev/mock-api.mjs --port 5380 &
node dev/mock-api.mjs --port 5381 --scale 2 &
HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \
HUB_USER=admin HUB_PASSWORD=admin HUB_PORT=5390 npm run hub
```
Then open http://localhost:5390. Tests: `npx vitest run hub`.
## Docker
```sh
docker run --rm -p 8080:8080 \
-e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... \
git.shx.one/denozord/technitium-hub:latest
```
The image is built by `docker/Dockerfile.hub` and published by `.gitea/workflows/hub.yaml`
(`latest` from `main`, `sha-<7>`, and `X.Y.Z` / `X.Y` from a `hub-vX.Y.Z` tag). A compose
example with Traefik is in `docs/compose.hub.example.yaml`.