- 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 <[email protected]>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
/*
|
|
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)
|
|
}
|
|
}
|
|
}
|