Blocking: count Advanced Blocking, Dashboard layout and KPI cards
CI / check (push) Successful in 3m7s
Hub / check (push) Successful in 53s
CI / image (push) Successful in 1m29s
Hub / image (push) Successful in 1m19s

- Status, sidebar mark and header treat a server that blocks with the
  Advanced Blocking app as blocking, per server behind the hub.
- Overview panel summarising the app's groups; Lists and Rules show and
  edit the app's list URLs and rules (read, change one field, write back).
- Block List Domains adds the app's list domains, counted by the hub
  (hub/blocking/app-lists/count, public hosts only unless
  HUB_LISTS_ALLOW_PRIVATE=1) or by the browser where CORS allows.
- Header and KPI cards follow the Dashboard: selector in the chrome slot,
  Compare servers in the header actions, period under the title, StatCard
  tiles without sparklines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 18:34:17 +07:00
1 parent b396bfd317
commit 172e06f1ce
40 files changed
+3126 -208

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants 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_LISTS_ALLOW_PRIVATE` | off | `1` lets the hub download the Advanced Blocking block lists (to count their domains, `hub/blocking/app-lists/count`) from private addresses. Off, only public addresses are fetched, so a hub cannot be pointed at your network. Counts are cached for 6 hours. |
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
| `HUB_STATIC` | `./dist` | The console's build to serve. |
+219
View File
@@ -0,0 +1,219 @@
// @vitest-environment node
/*
Stage 9: counting the domains in an Advanced Blocking app's block lists. The unit tests inject
the fetch and the DNS lookup; the hub tests run against fake servers and a fake list server.
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { AppLists, DomainCounter, countDomains, publicAddress } from './app-lists.ts'
import type { HubServer } from './config.ts'
import type { Upstream } from './upstream.ts'
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
const server: HubServer = { id: 'a', url: 'http://a.invalid', token: 't' }
const config = (groups: unknown[]) => JSON.stringify({ enableBlocking: true, groups })
function upstreamWith(text: string | null): Upstream {
return { call: async () => ({ config: text }) } as unknown as Upstream
}
const HOSTS = '# hosts\n127.0.0.1 localhost\n0.0.0.0 a.example\n0.0.0.0 b.example\n0.0.0.0 a.example\n'
function listFetch(files: Record<string, string>, seen: string[] = []): typeof fetch {
return (async (input: URL | string) => {
const url = String(input)
seen.push(url)
const body = files[url]
return body === undefined ? new Response('nope', { status: 404 }) : new Response(body, { status: 200 })
}) as typeof fetch
}
const PUBLIC = async () => ['93.184.216.34']
describe('countDomains', () => {
it('counts hosts, plain and adblock lines once each', () => {
expect(countDomains(HOSTS)).toBe(2)
expect(countDomains('x.example\n*.y.example\n! c\n||z.example^$third-party\n@@||ok.example^\n[Adblock Plus]\n')).toBe(3)
})
it('counts across chunks that cut a line in two', () => {
const c = new DomainCounter()
c.push('0.0.0.0 a.exa')
c.push('mple\n0.0.0.0 b.example')
expect(c.end()).toBe(2)
})
})
describe('publicAddress', () => {
it('refuses loopback, private, link-local, shared and unspecified addresses', () => {
for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', '::', 'fe80::1', 'fd00::1', '::ffff:10.0.0.1']) {
expect(publicAddress(a), a).toBe(false)
}
})
it('accepts public ones', () => {
for (const a of ['93.184.216.34', '8.8.8.8', '2606:4700:4700::1111']) expect(publicAddress(a), a).toBe(true)
})
})
describe('AppLists', () => {
it('reads the block and adblock URLs of every group, strings or objects, each once, http(s) only', async () => {
const text = config([
{ name: 'one', blockListUrls: ['https://x.example/a', { url: 'https://x.example/b' }, 'file:///etc/hosts'], adblockListUrls: ['https://x.example/c'], regexBlockListUrls: ['https://x.example/re'] },
{ name: 'two', blockListUrls: ['https://x.example/a'], allowListUrls: ['https://x.example/allow'] },
])
const lists = new AppLists(upstreamWith(text))
expect(await lists.urlsOf(server, 'Advanced Blocking')).toEqual(['https://x.example/a', 'https://x.example/b', 'https://x.example/c'])
})
it('has no URLs for a missing, empty or broken config', async () => {
expect(await new AppLists(upstreamWith(null)).urlsOf(server, 'x')).toEqual([])
expect(await new AppLists(upstreamWith('{nope')).urlsOf(server, 'x')).toEqual([])
})
it('counts each list and totals them, and caches by URL', async () => {
const seen: string[] = []
const files = { 'https://x.example/a': HOSTS, 'https://x.example/b': 'c.example\n' }
let now = 1_000
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/b'] }])), {
fetch: listFetch(files, seen), lookup: PUBLIC, now: () => now,
})
const first = await lists.count(server, 'Advanced Blocking')
expect(first.lists).toEqual([{ url: 'https://x.example/a', count: 2 }, { url: 'https://x.example/b', count: 1 }])
expect(first.total).toBe(3)
await lists.count(server, 'Advanced Blocking')
expect(seen).toHaveLength(2)
now += 7 * 3_600_000
await lists.count(server, 'Advanced Blocking')
expect(seen).toHaveLength(4)
})
it('reports a list that fails, with a reason, and keeps the others', async () => {
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/gone'] }])), {
fetch: listFetch({ 'https://x.example/a': HOSTS }), lookup: PUBLIC,
})
const out = await lists.count(server, 'x')
expect(out.lists[0].count).toBe(2)
expect(out.lists[1]).toEqual({ url: 'https://x.example/gone', count: null, error: 'the list server answered 404' })
expect(out.total).toBe(2)
})
it('never fetches an address that is not public, or a name that resolves to one', async () => {
const seen: string[] = []
const lists = new AppLists(
upstreamWith(config([{ name: 'g', blockListUrls: ['http://127.0.0.1/a', 'http://[::1]/a', 'http://169.254.169.254/latest', 'https://internal.example/a', 'ftp://x.example/a'] }])),
{ fetch: listFetch({}, seen), lookup: async (h) => (h === 'internal.example' ? ['10.0.0.5'] : ['93.184.216.34']) },
)
const out = await lists.count(server, 'x')
expect(seen).toEqual([])
expect(out.lists.every((l) => l.count === null)).toBe(true)
expect(out.lists[0].error).toBe('the address of this list is not a public one')
})
it('checks every redirect, and stops after three', async () => {
const seen: string[] = []
const fetcher = (async (input: URL | string) => {
const url = String(input)
seen.push(url)
if (url === 'https://x.example/start') return new Response(null, { status: 302, headers: { location: 'http://10.0.0.1/inner' } })
if (url.startsWith('https://x.example/loop')) return new Response(null, { status: 302, headers: { location: 'https://x.example/loop' } })
return new Response('a.example\n')
}) as typeof fetch
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/start', 'https://x.example/loop'] }])), { fetch: fetcher, lookup: async (h) => (h === 'x.example' ? ['93.184.216.34'] : ['10.0.0.1']) })
const out = await lists.count(server, 'x')
expect(seen).not.toContain('http://10.0.0.1/inner')
expect(out.lists[0].error).toBe('the address of this list is not a public one')
expect(out.lists[1].error).toBe('too many redirects')
})
it('refuses a list over the size cap, declared or streamed', async () => {
const big = (async (input: URL | string) =>
String(input).endsWith('/declared')
? new Response('a.example\n', { headers: { 'content-length': '5000' } })
: new Response('a.example\n'.repeat(1000))) as typeof fetch
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/declared', 'https://x.example/streamed'] }])), { fetch: big, lookup: PUBLIC, maxBytes: 1000 })
const out = await lists.count(server, 'x')
expect(out.lists.map((l) => l.error)).toEqual(['the list is larger than the limit', 'the list is larger than the limit'])
})
it('times a slow list out', async () => {
const slow = ((_: URL | string, init?: RequestInit) =>
new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(Object.assign(new Error('x'), { name: 'AbortError' }))))) as typeof fetch
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/slow'] }])), { fetch: slow, lookup: PUBLIC, timeoutMs: 20 })
expect((await lists.count(server, 'x')).lists[0].error).toBe('timed out')
})
it('takes at most maxLists lists', async () => {
const urls = Array.from({ length: 10 }, (_, i) => `https://x.example/${i}`)
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: urls }])), { fetch: listFetch({}), lookup: PUBLIC, maxLists: 3 })
expect((await lists.count(server, 'x')).lists).toHaveLength(3)
})
})
describe('hub/blocking/app-lists/count', () => {
let a: Fake
let b: Fake
let hub: TestHub
let files: http.Server
let base = ''
beforeAll(async () => {
files = http.createServer((req, res) => {
res.setHeader('content-type', 'text/plain')
res.end(req.url === '/hosts' ? HOSTS : 'p.example\nq.example\nr.example\n')
})
await new Promise<void>((resolve) => files.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(files.address() as AddressInfo).port}`
a = await fake()
b = await fake()
a.on('apps/config/get', () => ({
response: { config: config([{ name: 'g', enableBlocking: true, blockListUrls: [`${base}/hosts`], adblockListUrls: [`${base}/other`] }]) },
}))
b.on('apps/config/get', () => ({ response: { config: null } }))
hub = await startHub(a, b, { HUB_LISTS_ALLOW_PRIVATE: '1' })
})
afterAll(() => {
hub.close()
a.server.close()
b.server.close()
files.close()
})
it('counts the lists of the chosen server with its read token', async () => {
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'a', name: 'Advanced Blocking' }, 'GET')
expect(r.code).toBe(200)
expect(r.body.response.total).toBe(5)
expect(r.body.response.lists).toEqual([{ url: `${base}/hosts`, count: 2 }, { url: `${base}/other`, count: 3 }])
const read = a.calls.find((c) => c.path === 'apps/config/get')
expect(read?.token).toBe('read-a')
expect(read?.query.get('name')).toBe('Advanced Blocking')
})
it('answers an empty list for a server whose app has no config', async () => {
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'b' }, 'GET')
expect(r.body.response).toEqual({ lists: [], total: 0 })
})
it('needs a server', async () => {
for (const node of ['', 'cluster', 'nope']) {
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node }, 'GET')
expect(r.code).toBe(400)
}
})
it('is refused without a session', async () => {
const res = await fetch(`${hub.base}/api/hub/blocking/app-lists/count?node=a`)
expect(((await res.json()) as { status: string }).status).toBe('invalid-token')
})
it('does not fetch private addresses unless the hub is told to', async () => {
const strict = await startHub(a, b)
const r = await strict.call('viewer', 'hub/blocking/app-lists/count', { node: 'a' }, 'GET')
strict.close()
expect(r.body.response.lists.map((l: { error?: string }) => l.error)).toEqual(['the address of this list is not a public one', 'the address of this list is not a public one'])
expect(r.body.response.total).toBe(0)
})
})
+304
View File
@@ -0,0 +1,304 @@
/*
How many domains the block lists of a server's Advanced Blocking app hold (stage 9). The app has
no API that says it, so the hub reads the app's config from the server (with the READ token, like
any `apps/config/get`), downloads each http(s) block list and counts the distinct domains.
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain domain
(`example.com`, `*.example.com`) and an Adblock Plus domain rule (`||example.com^`). Not counted:
blank lines, comments (`#`, `!`, `[`), Adblock exceptions (`@@`) and regular expressions. The
console has the same rules for the no-hub case (`src/lib/list-count.ts`); the two are kept in step
by hand and tested on the same sample.
The hub fetches a URL an administrator typed into a config, so it is careful where it goes:
- only `http:` and `https:`, no credentials in the URL;
- the host must resolve to public addresses only (no loopback, private, link-local, carrier-grade
NAT, multicast or unspecified ones, IPv4 or IPv6). Redirects are followed by hand, up to three,
and checked at every step. `HUB_LISTS_ALLOW_PRIVATE=1` lifts this for lists kept on the LAN.
The check resolves the name and the fetch resolves it again, so a name that changes between the
two is not caught; the hub is not a place an attacker can write DNS for;
- a timeout and a size cap per list, a cap on how many lists, and four downloads at a time.
Results are cached per URL for several hours (a failure for minutes), so opening the Overview
does not download a hundred megabytes again.
*/
import { lookup as dnsLookup } from 'node:dns/promises'
import { isIP } from 'node:net'
import type { HubServer } from './config.ts'
import type { Upstream } from './upstream.ts'
export interface ListCount {
url: string
count: number | null
error?: string
}
export interface ListCounts {
lists: ListCount[]
total: number
}
export interface AppListsOptions {
fetch?: typeof fetch
/** Resolves a host to its addresses. */
lookup?: (host: string) => Promise<string[]>
now?: () => number
allowPrivate?: boolean
/** How long a counted list is remembered (default 6 hours). */
ttlMs?: number
/** How long a failure is remembered (default 5 minutes). */
failureTtlMs?: number
timeoutMs?: number
maxBytes?: number
maxLists?: number
}
const HOURS = 3_600_000
const MAX_CACHED = 500
const MAX_REDIRECTS = 3
const CONCURRENCY = 4
/* --- counting --- */
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
function accept(raw: string, into: Set<string>): void {
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
into.add(name)
}
function countLine(line: string, into: Set<string>): void {
const t = line.trim()
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) return
if (t.startsWith('||')) {
const end = t.search(/[\^$/]/)
accept(end < 0 ? t.slice(2) : t.slice(2, end), into)
return
}
const tokens = t.split('#')[0].trim().split(/\s+/)
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, into)
else if (tokens.length === 1) accept(tokens[0], into)
}
/* Counts as text arrives in pieces, so a big list is never held whole. */
export class DomainCounter {
#domains = new Set<string>()
#rest = ''
push(chunk: string): void {
const lines = (this.#rest + chunk).split(/\r?\n/)
this.#rest = lines.pop() ?? ''
for (const line of lines) countLine(line, this.#domains)
}
end(): number {
countLine(this.#rest, this.#domains)
this.#rest = ''
return this.#domains.size
}
}
export function countDomains(text: string): number {
const c = new DomainCounter()
c.push(text)
return c.end()
}
/* --- where a fetch may go --- */
function ipv4Allowed(a: number, b: number): boolean {
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
if (a === 100 && b >= 64 && b <= 127) return false
if (a === 169 && b === 254) return false
if (a === 172 && b >= 16 && b <= 31) return false
if (a === 192 && b === 168) return false
if (a === 192 && b === 0) return false
if (a === 198 && (b === 18 || b === 19)) return false
return true
}
/** Whether an address is one a list may be fetched from: public unicast only. */
export function publicAddress(address: string): boolean {
const kind = isIP(address)
if (kind === 4) {
const [a, b] = address.split('.').map(Number)
return ipv4Allowed(a, b)
}
if (kind === 6) {
const a = address.toLowerCase()
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(a)
if (mapped !== null) return publicAddress(mapped[1])
if (a === '::' || a === '::1') return false
const first = parseInt(a.split(':')[0] || '0', 16)
if ((first & 0xfe00) === 0xfc00) return false // fc00::/7 unique local
if ((first & 0xffc0) === 0xfe80) return false // fe80::/10 link local
if ((first & 0xff00) === 0xff00) return false // multicast
return true
}
return false
}
async function defaultLookup(host: string): Promise<string[]> {
return (await dnsLookup(host, { all: true })).map((r) => r.address)
}
class Refused extends Error {}
/* --- the service --- */
interface Cached {
until: number
result: ListCount
}
export class AppLists {
#upstream: Upstream
#fetch: typeof fetch
#lookup: (host: string) => Promise<string[]>
#now: () => number
#allowPrivate: boolean
#ttl: number
#failureTtl: number
#timeout: number
#maxBytes: number
#maxLists: number
#cache = new Map<string, Cached>()
#inflight = new Map<string, Promise<ListCount>>()
constructor(upstream: Upstream, options: AppListsOptions = {}) {
this.#upstream = upstream
this.#fetch = options.fetch ?? fetch
this.#lookup = options.lookup ?? defaultLookup
this.#now = options.now ?? Date.now
this.#allowPrivate = options.allowPrivate ?? false
this.#ttl = options.ttlMs ?? 6 * HOURS
this.#failureTtl = options.failureTtlMs ?? 5 * 60_000
this.#timeout = options.timeoutMs ?? 20_000
this.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024
this.#maxLists = options.maxLists ?? 100
}
/** The distinct block list URLs (plain and Adblock) of the app's config on one server. */
async urlsOf(server: HubServer, appName: string): Promise<string[]> {
const response = (await this.#upstream.call(server, 'apps/config/get', { name: appName })) as { config?: string | null } | null
const text = response?.config
if (typeof text !== 'string' || text.trim() === '') return []
let root: unknown
try {
root = JSON.parse(text)
} catch {
return []
}
const groups = root !== null && typeof root === 'object' && Array.isArray((root as { groups?: unknown }).groups) ? ((root as { groups: unknown[] }).groups) : []
const urls = new Set<string>()
for (const g of groups) {
if (g === null || typeof g !== 'object') continue
for (const field of ['blockListUrls', 'adblockListUrls']) {
const list = (g as Record<string, unknown>)[field]
if (!Array.isArray(list)) continue
for (const e of list) {
const url = typeof e === 'string' ? e : e !== null && typeof e === 'object' && typeof (e as { url?: unknown }).url === 'string' ? (e as { url: string }).url : ''
if (/^https?:\/\//i.test(url.trim())) urls.add(url.trim())
}
}
}
return [...urls]
}
async count(server: HubServer, appName: string): Promise<ListCounts> {
const urls = (await this.urlsOf(server, appName)).slice(0, this.#maxLists)
const lists: ListCount[] = new Array(urls.length)
let next = 0
const worker = async () => {
while (next < urls.length) {
const i = next++
lists[i] = await this.countUrl(urls[i])
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, urls.length) }, worker))
return { lists, total: lists.reduce((n, l) => n + (l.count ?? 0), 0) }
}
countUrl(url: string): Promise<ListCount> {
const hit = this.#cache.get(url)
if (hit !== undefined && hit.until > this.#now()) return Promise.resolve(hit.result)
const running = this.#inflight.get(url)
if (running !== undefined) return running
const job = this.#download(url).then((result) => {
if (this.#cache.size >= MAX_CACHED) this.#prune()
this.#cache.set(url, { until: this.#now() + (result.count === null ? this.#failureTtl : this.#ttl), result })
return result
})
this.#inflight.set(url, job)
void job.finally(() => this.#inflight.delete(url))
return job
}
#prune(): void {
const now = this.#now()
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
}
async #allowed(url: URL): Promise<void> {
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Refused('only http and https lists are read')
if (url.username !== '' || url.password !== '') throw new Refused('a list address may not carry credentials')
if (this.#allowPrivate) return
const host = url.hostname.replace(/^\[|\]$/g, '')
const addresses = isIP(host) !== 0 ? [host] : await this.#lookup(host)
if (addresses.length === 0 || !addresses.every(publicAddress)) throw new Refused('the address of this list is not a public one')
}
async #download(start: string): Promise<ListCount> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.#timeout)
try {
let url = new URL(start)
for (let hop = 0; ; hop++) {
await this.#allowed(url)
const res = await this.#fetch(url, { redirect: 'manual', signal: controller.signal, headers: { accept: 'text/plain, */*' } })
if (res.status >= 300 && res.status < 400 && res.headers.get('location') !== null) {
void res.body?.cancel()
if (hop >= MAX_REDIRECTS) throw new Refused('too many redirects')
url = new URL(res.headers.get('location') as string, url)
continue
}
if (!res.ok) {
void res.body?.cancel()
throw new Refused(`the list server answered ${res.status}`)
}
const declared = Number(res.headers.get('content-length') ?? 0)
if (declared > this.#maxBytes) {
void res.body?.cancel()
throw new Refused('the list is larger than the limit')
}
const counter = new DomainCounter()
const decoder = new TextDecoder()
let bytes = 0
if (res.body !== null) {
const reader = res.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
bytes += value.byteLength
if (bytes > this.#maxBytes) {
void reader.cancel()
throw new Refused('the list is larger than the limit')
}
counter.push(decoder.decode(value, { stream: true }))
}
counter.push(decoder.decode())
}
return { url: start, count: counter.end() }
}
} catch (e) {
const message =
e instanceof Refused ? e.message : e instanceof Error && e.name === 'AbortError' ? 'timed out' : 'could not be downloaded'
return { url: start, count: null, error: message }
} finally {
clearTimeout(timer)
}
}
}
+3
View File
@@ -36,6 +36,8 @@ export interface HubConfig {
webhookUrl: string | null
/** Signs the notices with HMAC-SHA256 (HUB_WEBHOOK_SECRET); null when not set. Never logged. */
webhookSecret: string | null
/** Lets the block list counter fetch from private addresses (HUB_LISTS_ALLOW_PRIVATE=1), for lists kept on the LAN. */
listsAllowPrivate: boolean
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
domain: string
/** The console's static build. */
@@ -111,6 +113,7 @@ export function parseConfig(env: Env): HubConfig {
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
webhookUrl: webhookUrl === '' ? null : webhookUrl,
webhookSecret: (env.HUB_WEBHOOK_SECRET ?? '') === '' ? null : (env.HUB_WEBHOOK_SECRET as string),
listsAllowPrivate: ['1', 'true'].includes((env.HUB_LISTS_ALLOW_PRIVATE ?? '').trim().toLowerCase()),
domain: env.HUB_DOMAIN?.trim() || 'hub',
staticDir: env.HUB_STATIC?.trim() || './dist',
}
+2
View File
@@ -185,6 +185,8 @@ export const ROUTES: Record<string, Route> = {
'hub/settings/baseline/save': writeAll('admin'),
'hub/settings/overrides/save': writeAll('admin'),
'hub/settings/apply': writeAll('admin'),
// The domains in an Advanced Blocking app's block lists, counted by the hub (app-lists.ts). One server's app.
'hub/blocking/app-lists/count': readOne(),
'hub/blocking/lists/compare': read('admin'),
'hub/blocking/lists/preview': read('admin'),
'hub/blocking/lists/baseline/import': writeAll('admin'),
+16
View File
@@ -35,6 +35,7 @@ import { Drift } from './drift.ts'
import { Webhook, type WebhookOptions } from './webhook.ts'
import { Profiles } from './profiles.ts'
import { SettingsConfig } from './settings-config.ts'
import { AppLists } from './app-lists.ts'
import { BLOCKING_FIELDS } from './settings-sync.ts'
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
import {
@@ -167,6 +168,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
const appsConfig = new AppsConfig(config, upstream)
const zonesSync = new ZonesSync(config, upstream)
const settingsConfig = new SettingsConfig(config, upstream)
const appLists = new AppLists(upstream, { allowPrivate: config.listsAllowPrivate })
const profiles = new Profiles(config, upstream, appsConfig, settingsConfig, writer)
const byId = new Map(config.servers.map((s) => [s.id, s]))
@@ -917,6 +919,20 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await profilesRead(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/blocking/app-lists/count') {
const server = byId.get(params.node ?? '')
if (server === undefined) {
send(res, 400, fail('Choose a server: the lists are counted for one server at a time.'))
return
}
try {
send(res, 200, ok(await appLists.count(server, params.name || 'Advanced Blocking')))
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsRead(res, apiPath, params, who, ip)
return