Blocking: count Advanced Blocking, Dashboard layout and KPI cards
- 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:
1 parent
b396bfd317
commit
172e06f1ce
40 files changed
+3126
-208
No files matched your search
@@ -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. |
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user