diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index e2b66f9..96d687c 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -176,7 +176,7 @@ const settings = { cacheMaximumEntries: 10000, cacheMinimumRecordTtl: 10, cacheMaximumRecordTtl: 86400, cacheNegativeRecordTtl: 300, cacheFailureRecordTtl: 60, enableCachePrefetch: true, cachePrefetchEligibility: 2, cachePrefetchTrigger: 9, enableBlocking: true, allowTxtBlockingReport: true, temporaryDisableBlockingTill: null, blockingBypassList: [], blockingType: 'NxDomain', blockingAnswerTtl: 30, - customBlockingAddresses: [], blockListUrls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'], + customBlockingAddresses: [], blockListUrls: ['https://lists.invalid/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'], blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: iso(now + 3 * 3_600_000), proxy: null, forwarders: ['1.1.1.1', '9.9.9.9'], forwarderProtocol: 'Udp', concurrentForwarding: true, forwarderRetries: 3, forwarderTimeout: 2000, forwarderConcurrency: 2, loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365, @@ -220,10 +220,42 @@ if (VARIANT === 'b') { apps = apps.filter((a) => a.name !== 'Split Horizon') apps = apps.map((a) => (a.name === 'Query Logs (Sqlite)' ? { ...a, version: '7.1', updateAvailable: true, updateVersion: '7.2', updateUrl: 'https://download.technitium.com/dns/apps/QueryLogsSqliteApp-v7.2.zip' } : a)) } +// A small fake list the mock serves itself at /lists/.txt (no internet), so the Advanced Blocking +// "Block List Domains" count has something to download. Open to any origin, as a public list host is. +const FAKE_LISTS = { + 'hosts.txt': ['# fake hosts list', '127.0.0.1 localhost', ...Array.from({ length: 120 }, (_, i) => `0.0.0.0 ads${i}.tracker.example`)].join('\n'), + 'easylist.txt': ['[Adblock Plus 2.0]', '! fake adblock list', ...Array.from({ length: 45 }, (_, i) => `||banner${i}.adnet.example^`)].join('\n'), +} +const listUrl = (name) => `http://127.0.0.1:${PORT}/lists/${name}` +const group = (over) => ({ + allowTxtBlockingReport: true, blockAsNxDomain: true, blockingAddresses: ['0.0.0.0', '::'], + allowed: [], blocked: [], allowListUrls: [], blockListUrls: [], allowedRegex: [], blockedRegex: [], + regexAllowListUrls: [], regexBlockListUrls: [], adblockListUrls: [], ...over, +}) const appConfigs = { 'Advanced Blocking': VARIANT === 'b' - ? { enableBlocking: true, blockingAnswerTtl: 60, localGroup: 'office', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] } - : { enableBlocking: true, blockingAnswerTtl: 30, localGroup: 'home', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] }, + ? { + enableBlocking: true, blockingAnswerTtl: 60, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' }, + networkGroupMap: { '0.0.0.0/0': 'default', '::/0': 'default' }, + groups: [ + group({ name: 'default', enableBlocking: true, blocked: ['tracker.example'], blockedRegex: ['^ads\\.'], blockListUrls: [listUrl('hosts.txt')], adblockListUrls: [listUrl('easylist.txt')] }), + group({ name: 'bypass', enableBlocking: false }), + ], + } + : { + enableBlocking: true, blockingAnswerTtl: 30, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' }, + networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone', '::/0': 'everyone' }, + groups: [ + group({ + name: 'everyone', enableBlocking: true, blocked: ['ads.example.com', 'telemetry.example.net'], allowed: ['good.example.org'], + blockListUrls: [listUrl('hosts.txt'), 'https://lists.invalid/hosts'], + allowListUrls: ['https://example.org/allow.txt'], blockedRegex: ['^ads\\.', '^track\\d+\\.'], allowedRegex: ['^cdn\\.good\\.'], + regexBlockListUrls: ['https://example.org/regex.txt'], adblockListUrls: [listUrl('easylist.txt')], + }), + group({ name: 'family', enableBlocking: true, blocked: ['games.example.com'], blockListUrls: [listUrl('hosts.txt')], regexAllowListUrls: ['https://example.org/regex-allow.txt'] }), + group({ name: 'bypass', enableBlocking: false }), + ], + }, 'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' }, 'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } }, } @@ -438,6 +470,15 @@ http.createServer((req, res) => { const q = new URLSearchParams(url.search) if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v) if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body) + const fakeList = url.pathname.match(/^\/lists\/([\w.-]+)$/) + if (fakeList !== null) { + const text = FAKE_LISTS[fakeList[1]] + res.statusCode = text === undefined ? 404 : 200 + res.setHeader('access-control-allow-origin', '*') + res.setHeader('content-type', 'text/plain') + res.end(text ?? 'not found') + return + } const file = fileFor(path, q) if (file !== undefined) { res.setHeader('content-type', 'text/plain') diff --git a/hub/README.md b/hub/README.md index 5f0bc30..c60dc66 100644 --- a/hub/README.md +++ b/hub/README.md @@ -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. | diff --git a/hub/app-lists.test.ts b/hub/app-lists.test.ts new file mode 100644 index 0000000..1d8cd99 --- /dev/null +++ b/hub/app-lists.test.ts @@ -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, 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((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) + }) +}) diff --git a/hub/app-lists.ts b/hub/app-lists.ts new file mode 100644 index 0000000..dbb091f --- /dev/null +++ b/hub/app-lists.ts @@ -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 + 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): 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): 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() + #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 { + 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 + #now: () => number + #allowPrivate: boolean + #ttl: number + #failureTtl: number + #timeout: number + #maxBytes: number + #maxLists: number + #cache = new Map() + #inflight = new Map>() + + 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 { + 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() + for (const g of groups) { + if (g === null || typeof g !== 'object') continue + for (const field of ['blockListUrls', 'adblockListUrls']) { + const list = (g as Record)[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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/hub/config.ts b/hub/config.ts index 5112ca5..2beabf5 100644 --- a/hub/config.ts +++ b/hub/config.ts @@ -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', } diff --git a/hub/routes.ts b/hub/routes.ts index 2e7c101..8b01981 100644 --- a/hub/routes.ts +++ b/hub/routes.ts @@ -185,6 +185,8 @@ export const ROUTES: Record = { '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'), diff --git a/hub/server.ts b/hub/server.ts index 866403d..66dd1ca 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -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 diff --git a/src/api/app-lists.ts b/src/api/app-lists.ts new file mode 100644 index 0000000..1447482 --- /dev/null +++ b/src/api/app-lists.ts @@ -0,0 +1,26 @@ +import { apiRequest, type ApiOutcome } from './client' + +/* +`hub/blocking/app-lists/count`: how many domains the block lists of the Advanced Blocking app +hold on one server. The hub downloads them (the app has no API for it) and caches the counts +for hours. A list it could not read has `count: null` and the reason in `error`. +*/ +export interface ListCount { + url: string + count: number | null + error?: string +} + +export interface ListCounts { + lists: ListCount[] + /** The sum of the lists that were counted. */ + total: number +} + +export function getAppListCounts( + token: string | null, + name: string, + node: string, +): Promise> { + return apiRequest('hub/blocking/app-lists/count', { token, body: { name, node } }) +} diff --git a/src/app/BlockingMark.tsx b/src/app/BlockingMark.tsx index 9c6abfd..f82bd4a 100644 --- a/src/app/BlockingMark.tsx +++ b/src/app/BlockingMark.tsx @@ -4,7 +4,8 @@ import styles from './BlockingMark.module.css' /* The sidebar's mark on Blocking: whether blocking is disabled or paused, from -anywhere in the console. OURS; what it says and when it reads are in +anywhere in the console. Nothing while the Advanced Blocking app is blocking in the +built-in blocking's place. OURS; what it says and when it reads are in `screens/blocking/blocking-state.ts`. The only count or state the sidebar carries. The link's accessible name gains the sentence (`Blocking, paused until 14:30`) diff --git a/src/app/Shell.blocking.app.test.tsx b/src/app/Shell.blocking.app.test.tsx new file mode 100644 index 0000000..4636518 --- /dev/null +++ b/src/app/Shell.blocking.app.test.tsx @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import { Shell, type ShellSession } from './Shell' +import { ThemeProvider } from '../theme/ThemeProvider' +import * as client from '../api/client' +import * as settings from '../api/settings' +import type { DnsSettings } from '../api/settings' +import { forgetRoot } from './route' +import { AB_CONFIG, serveAdvancedBlocking } from '../screens/blocking/advanced-blocking.fixture' + +/* +The sidebar's mark and the header's status when the built-in blocking is off but the Advanced +Blocking app blocks (`blocking-state.ts`): no OFF mark, no attention; and as before when the app +is not blocking. +*/ + +function session(): ShellSession { + const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs'] + return { + token: 'tok', + displayName: 'Administrator', + username: 'admin', + info: { + version: '15.6', + uptimestamp: '2026-10-15T08:00:00Z', + dnsServerDomain: 'dns.example.net', + permissions: Object.fromEntries(all.map((s) => [s, { canView: true, canModify: true, canDelete: true }])), + }, + } +} + +const mount = () => render( {}} />) +const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ }) + +beforeEach(() => { + localStorage.clear() + forgetRoot() + window.history.replaceState(null, '', '/about/') + vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never) +}) +afterEach(() => vi.restoreAllMocks()) + +describe('Shell: the Blocking mark with the Advanced Blocking app', () => { + it('built-in off, the app blocking: no mark', async () => { + vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings) + const { get } = serveAdvancedBlocking() + mount() + await vi.waitFor(() => expect(get).toHaveBeenCalled()) + // Let the answer land: the mark must stay away, not just not have come yet. + await new Promise((r) => setTimeout(r, 50)) + await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking')) + expect(entry()).not.toHaveTextContent('Off') + expect(entry()).not.toHaveAttribute('title') + }) + + it('built-in off, the app installed but not blocking: Off, as before', async () => { + vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings) + serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false }) + mount() + await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled')) + }) +}) diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx index 87cdfd0..9959543 100644 --- a/src/app/Shell.tsx +++ b/src/app/Shell.tsx @@ -37,7 +37,7 @@ import { HubServerIds } from '../lib/hub-all' import { FooterLinks } from '../ui/FooterLinks' import { Versions } from './Versions' import { BlockingMark } from './BlockingMark' -import { useBlockingMark } from '../screens/blocking/blocking-state' +import { useBlockingMark, useHubBlockingMark } from '../screens/blocking/blocking-state' import { useHubWarnings } from '../api/hub-warnings' import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift' import { DriftStatus } from './DriftStatus' @@ -280,7 +280,13 @@ export function Shell({ const current = sections.find((s) => s.id === active) ?? sections[0] /* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */ - const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'), hub) + /* Behind a hub the Advanced Blocking app is asked on the first server (the one `settings/get` answers for). */ + const markNode = hub ? (session.info?.clusterNodes?.[0]?.name ?? '') : '' + const showMark = sections.some((s) => s.id === 'blocking') + const singleMark = useBlockingMark(session.token, permissions, showMark && !hub, hub, markNode) + /* Behind a hub, every server counts: the mark names the ones that block by nothing. */ + const hubNow = useHubBlockingMark(session.token, permissions, showMark && hub, hubServers) + const blockingNow = hub ? hubNow : singleMark /* The jumps other screens' row menus make (`app/handoff.ts`). The section switch diff --git a/src/lib/list-count.test.ts b/src/lib/list-count.test.ts new file mode 100644 index 0000000..c03171c --- /dev/null +++ b/src/lib/list-count.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { countDomains } from './list-count' + +describe('countDomains', () => { + it('counts hosts lines, skipping comments and the hosts file own names', () => { + const text = [ + '# Title: a list', + '127.0.0.1 localhost', + '::1 ip6-localhost', + '0.0.0.0 ads.example.com', + '0.0.0.0 track.example.com # inline', + '0.0.0.0 ads.example.com', + '', + ].join('\r\n') + expect(countDomains(text)).toBe(2) + }) + + it('counts plain domains and wildcards once each', () => { + expect(countDomains('a.example\nB.example\n*.c.example\na.example\n! note\n')).toBe(3) + }) + + it('counts adblock domain rules, not exceptions, options-only lines or regexes', () => { + const text = ['[Adblock Plus 2.0]', '! Title', '||ads.example.com^', '||t.example.net^$third-party', '@@||ok.example.com^', '/banner[0-9]+/', '##.ad'].join('\n') + expect(countDomains(text)).toBe(2) + }) + + it('counts several names on one hosts line', () => { + expect(countDomains('0.0.0.0 a.example b.example')).toBe(2) + }) +}) diff --git a/src/lib/list-count.ts b/src/lib/list-count.ts new file mode 100644 index 0000000..c8ba9d8 --- /dev/null +++ b/src/lib/list-count.ts @@ -0,0 +1,39 @@ +/* +How many distinct domains a block list file holds. OURS, for the Blocking Overview's +"Block List Domains" figure when the Advanced Blocking app is in use: the app has no API +that says it, so the lists are downloaded and counted. The hub has its own copy of this +function (`hub/app-lists.ts`), kept in step by hand because the hub does not share code with +the console build; both are tested against the same sample. + +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^`, +with or without options). Not counted: blank lines, comments (`#`, `!`, `[Adblock Plus]`), +Adblock exceptions (`@@`), regular expressions, and the hosts file's own names (`localhost`, +`broadcasthost`, `ip6-*`). +*/ +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): void { + const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase() + if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return + into.add(name) +} + +export function countDomains(text: string): number { + const domains = new Set() + for (const line of text.split(/\r?\n/)) { + const t = line.trim() + if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) continue + if (t.startsWith('||')) { + const end = t.search(/[\^$/]/) + accept(end < 0 ? t.slice(2) : t.slice(2, end), domains) + continue + } + 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, domains) + else if (tokens.length === 1) accept(tokens[0], domains) + } + return domains.size +} diff --git a/src/screens/blocking/AdvancedBlockingEntries.test.tsx b/src/screens/blocking/AdvancedBlockingEntries.test.tsx new file mode 100644 index 0000000..2f793eb --- /dev/null +++ b/src/screens/blocking/AdvancedBlockingEntries.test.tsx @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { HubWritable } from '../../lib/hub-write' +import { AdvancedBlockingEntries } from './AdvancedBlockingEntries' +import { AB_CONFIG, serveAdvancedBlocking, written } from './advanced-blocking.fixture' +import type { Json } from './advanced-blocking' + +afterEach(() => vi.restoreAllMocks()) + +const groupOf = (root: Json, name: string) => (root.groups as Json[]).find((g) => g.name === name)! + +function lists(permissions: Record | undefined = undefined, onNotice = vi.fn()) { + return render() +} + +function inHub(node: string, writable: string[]) { + return render( + + + {}} /> + + , + ) +} + +describe('Advanced Blocking lists', () => { + it('lists the urls of every group, labelled by group and type', async () => { + serveAdvancedBlocking() + lists() + expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument() + expect(screen.getByText('Advanced Blocking lists')).toBeInTheDocument() + expect(screen.getByText('https://lists.example/allow')).toBeInTheDocument() + expect(screen.getByText('https://lists.example/easy')).toBeInTheDocument() + expect(screen.getAllByText('everyone').length).toBeGreaterThan(0) + expect(screen.getByText('Adblock list')).toBeInTheDocument() + }) + + it('draws nothing when the app is not installed', async () => { + const { get } = serveAdvancedBlocking(AB_CONFIG, false) + const { container } = lists() + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + expect(container).toBeEmptyDOMElement() + expect(get).not.toHaveBeenCalled() + }) + + it('adds a list: reads the config, changes only that group field, writes the whole text back', async () => { + const { get, set } = serveAdvancedBlocking() + const onNotice = vi.fn() + lists(undefined, onNotice) + await screen.findByText('https://lists.example/hosts') + await userEvent.type(screen.getByLabelText('List URL'), 'https://lists.example/new') + await userEvent.click(screen.getByRole('button', { name: 'Add list' })) + await waitFor(() => expect(set).toHaveBeenCalledTimes(1)) + expect(set.mock.calls[0][1]).toBe('Advanced Blocking') + const root = written(set) + expect(groupOf(root, 'everyone').blockListUrls).toEqual(['https://lists.example/hosts', 'https://lists.example/more', 'https://lists.example/new']) + expect(groupOf(root, 'everyone').blocked).toEqual(['ads.example.com']) + expect(groupOf(root, 'family')).toEqual(groupOf(AB_CONFIG, 'family')) + expect(root.futureKey).toEqual({ keep: ['me'] }) + expect(Object.keys(root)).toEqual(Object.keys(AB_CONFIG)) + // Read again just before the write. + expect(get.mock.calls.length).toBeGreaterThanOrEqual(2) + await waitFor(() => expect(onNotice).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))) + }) + + it('refuses a value that is not a URL, and a repeat, without writing', async () => { + const { set } = serveAdvancedBlocking() + lists() + await screen.findByText('https://lists.example/hosts') + await userEvent.type(screen.getByLabelText('List URL'), 'not a url') + await userEvent.click(screen.getByRole('button', { name: 'Add list' })) + expect(screen.getByText('Enter a URL starting with http://, https:// or file://.')).toBeInTheDocument() + await userEvent.clear(screen.getByLabelText('List URL')) + await userEvent.type(screen.getByLabelText('List URL'), 'https://lists.example/hosts') + await userEvent.click(screen.getByRole('button', { name: 'Add list' })) + expect(screen.getByText('This list is already in the group.')).toBeInTheDocument() + expect(set).not.toHaveBeenCalled() + }) + + it('removes a list', async () => { + const { set } = serveAdvancedBlocking() + lists() + await userEvent.click(await screen.findByRole('button', { name: 'Remove https://lists.example/more' })) + await waitFor(() => expect(set).toHaveBeenCalled()) + expect(groupOf(written(set), 'everyone').blockListUrls).toEqual(['https://lists.example/hosts']) + }) + + it('edits a list in place', async () => { + const { set } = serveAdvancedBlocking() + lists() + await userEvent.click(await screen.findByRole('button', { name: 'Edit https://lists.example/allow' })) + const field = screen.getByRole('textbox', { name: 'Edit https://lists.example/allow' }) + await userEvent.clear(field) + await userEvent.type(field, 'https://lists.example/allow2') + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(set).toHaveBeenCalled()) + expect(groupOf(written(set), 'everyone').allowListUrls).toEqual(['https://lists.example/allow2']) + }) + + it('writes back in the form it read: an object entry keeps its other keys', async () => { + const config = { ...AB_CONFIG, groups: [{ name: 'g', enableBlocking: true, blockListUrls: [{ url: 'https://a.example/l', interval: 6 }] }] } + const { set } = serveAdvancedBlocking(config) + lists() + await userEvent.type(await screen.findByLabelText('List URL'), 'https://b.example/l') + await userEvent.click(screen.getByRole('button', { name: 'Add list' })) + await waitFor(() => expect(set).toHaveBeenCalled()) + expect(groupOf(written(set), 'g').blockListUrls).toEqual([{ url: 'https://a.example/l', interval: 6 }, { url: 'https://b.example/l' }]) + }) + + it('without Apps.canModify the controls stay, switched off and named', async () => { + serveAdvancedBlocking() + lists({ Apps: { canView: true, canModify: false, canDelete: false } }) + await screen.findByText('https://lists.example/hosts') + expect(screen.getByRole('button', { name: 'Add list' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Remove https://lists.example/more' })).toBeDisabled() + }) + + it('without Apps.canView it reads nothing', () => { + const { get } = serveAdvancedBlocking() + const { container } = lists({ Apps: { canView: false, canModify: false, canDelete: false } }) + expect(container).toBeEmptyDOMElement() + expect(get).not.toHaveBeenCalled() + }) + + it('says so, with Retry, when the config cannot be read', async () => { + const { get } = serveAdvancedBlocking() + get.mockResolvedValueOnce({ kind: 'error', message: 'Boom.' } as never) + lists() + expect(await screen.findByText(/Could not read the Advanced Blocking config/)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument() + }) + + it('behind a hub with no single server chosen it reads nothing and offers no write', () => { + const { get } = serveAdvancedBlocking() + const { container } = inHub('', ['a']) + expect(container).toBeEmptyDOMElement() + expect(get).not.toHaveBeenCalled() + }) + + it('behind a hub, a server the hub cannot write to is read only and says why', async () => { + const { set } = serveAdvancedBlocking() + inHub('a', []) + await screen.findByText('https://lists.example/hosts') + expect(screen.getByRole('button', { name: 'Add list' })).toBeDisabled() + expect(screen.getByText(/No admin token configured on the hub/)).toBeInTheDocument() + expect(set).not.toHaveBeenCalled() + }) + + it('behind a hub, a writable server is read and written with its name', async () => { + const { get, set } = serveAdvancedBlocking() + inHub('a', ['a']) + await userEvent.click(await screen.findByRole('button', { name: 'Remove https://lists.example/more' })) + await waitFor(() => expect(set).toHaveBeenCalled()) + expect(set.mock.calls[0][3]).toBe('a') + expect(get.mock.calls.every((c) => c[2] === 'a')).toBe(true) + }) +}) + +describe('Advanced Blocking rules', () => { + const rules = () => render( {}} />) + + it('lists the typed-in rules and adds a blocked regex without touching the other fields', async () => { + const { set } = serveAdvancedBlocking() + rules() + expect(await screen.findByText('ads.example.com')).toBeInTheDocument() + expect(screen.getByText('games.example.com')).toBeInTheDocument() + await userEvent.click(screen.getByRole('combobox', { name: 'Type' })) + await userEvent.click(await screen.findByRole('option', { name: 'Blocked regex' })) + await userEvent.type(screen.getByLabelText('Domain or regex'), '^track') + await userEvent.click(screen.getByRole('button', { name: 'Add rule' })) + await waitFor(() => expect(set).toHaveBeenCalled()) + const g = groupOf(written(set), 'everyone') + expect(g.blockedRegex).toEqual(['^ads\\.', '^track']) + expect(g.blocked).toEqual(['ads.example.com']) + }) + + it('refuses a domain with spaces', async () => { + const { set } = serveAdvancedBlocking() + rules() + await userEvent.type(await screen.findByLabelText('Domain or regex'), 'two words') + await userEvent.click(screen.getByRole('button', { name: 'Add rule' })) + expect(screen.getByText('A domain has no spaces.')).toBeInTheDocument() + expect(set).not.toHaveBeenCalled() + }) + + it('filters', async () => { + serveAdvancedBlocking() + rules() + await userEvent.type(await screen.findByLabelText('Filter Advanced Blocking rules'), 'games') + expect(screen.queryByText('ads.example.com')).not.toBeInTheDocument() + expect(screen.getByText('games.example.com')).toBeInTheDocument() + }) +}) diff --git a/src/screens/blocking/AdvancedBlockingEntries.tsx b/src/screens/blocking/AdvancedBlockingEntries.tsx new file mode 100644 index 0000000..ee8b8de --- /dev/null +++ b/src/screens/blocking/AdvancedBlockingEntries.tsx @@ -0,0 +1,318 @@ +import { useMemo, useState } from 'react' +import { Panel, Body } from '../../ui/Panel' +import { DataTable } from '../../ui/DataTable' +import { Field, Input, Select } from '../../ui/Field' +import { PermissionButton } from '../../ui/PermissionButton' +import { Button } from '../../ui/Button' +import { Failure } from '../../ui/Empty' +import { Icon } from '../../ui/Icon' +import { Pagination } from '../../ui/Pagination' +import { Tag } from '../../ui/Tag' +import { UrlText } from '../../ui/UrlText' +import { Skeleton } from '@/components/ui/skeleton' +import { HubWriteHint } from '../../ui/HubWriteHint' +import { useHubWrite } from '../../lib/hub-write' +import { pageWindow } from '../../lib/pagination' +import type { Notice } from '../../lib/notice' +import { + URL_FIELDS, RULE_FIELDS, addEntry, editEntry, entriesOf, entryText, groupName, groupsOf, isUrl, removeEntry, + APP_NAME, type GroupField, type Json, +} from './advanced-blocking' +import { changeAppConfig, useAdvancedBlocking } from './advanced-blocking-state' +import { missing, type Permissions } from './permissions' +import { pageOf } from './rules-model' +import shared from './Blocking.module.css' + +/* +The Advanced Blocking app's own lists (Lists tab) or rules (Rules tab), listed per group +under the built-in ones and labelled as the app's. OURS. One component for both because +they differ only in the fields they hold and in what a value must look like. + +Every change is a read-modify-write of the app's whole config (`changeAppConfig`): it +reads the config, changes ONLY the touched group's field and writes it back, so nothing +the console does not know about is lost. Writes are saved at once, one per click, unlike +the built-in lists, which wait for Save: the app has no "unsaved" state to hold them in. + +Writing needs `Apps.canModify`, and behind a hub a single server the hub can write to +(`useHubWrite`); without them the controls stay, switched off, and say why. +*/ + +type Row = { key: string; group: string; field: GroupField; index: number; value: string; off: boolean } + +const WITH_WHITESPACE = /\s/ + +/* Where an entry is NOW, in the config read just before the write: by its text, so an edit made elsewhere since the page loaded cannot make the index point at another entry. */ +function locate(root: Json, row: Row): number { + const g = groupsOf(root).find((x) => groupName(x) === row.group) + return g == null ? -1 : entriesOf(g, row.field).findIndex((e) => entryText(e) === row.value) +} + +export function AdvancedBlockingEntries({ + kind, + token, + permissions, + node = '', + onNotice, +}: { + kind: 'lists' | 'rules' + token: string | null + permissions: Permissions + /** Behind a hub: the server in view. Empty elsewhere. */ + node?: string + onNotice: (n: Notice) => void +}) { + const ab = useAdvancedBlocking(token, permissions, node) + const modifyNeed = missing(permissions, 'Apps.canModify') + const write = useHubWrite(node, modifyNeed == null) + const fields = kind === 'lists' ? URL_FIELDS : RULE_FIELDS + const noun = kind === 'lists' ? 'list' : 'rule' + + const [group, setGroup] = useState('') + const [field, setField] = useState(fields[0].field) + const [text, setText] = useState('') + const [problem, setProblem] = useState(null) + const [busy, setBusy] = useState(false) + const [editing, setEditing] = useState<{ key: string; value: string } | null>(null) + const [query, setQuery] = useState('') + const [page, setPage] = useState(1) + + const root: Json | null = ab.state.kind === 'ready' ? ab.state.root : null + const appName = ab.state.kind === 'ready' ? ab.state.app.name : APP_NAME + const groups = useMemo(() => (root == null ? [] : groupsOf(root)), [root]) + const rows = useMemo(() => { + const out: Row[] = [] + for (const g of groups) { + for (const { field: f } of fields) { + entriesOf(g, f).forEach((e, index) => { + out.push({ key: `${groupName(g)}\n${f}\n${index}`, group: groupName(g), field: f, index, value: entryText(e), off: g.enableBlocking !== true }) + }) + } + } + return out + }, [groups, fields]) + + if (ab.state.kind === 'none') return null + + const title = `${APP_NAME} ${kind}` + if (ab.state.kind === 'loading') { + return ( + + +
+ + +
+ +
+ ) + } + if (ab.state.kind === 'failed') { + return ( + + + + Could not read the {APP_NAME} config. {ab.state.message}{' '} + + + + + ) + } + + const chosen = groups.some((g) => groupName(g) === group) ? group : groupName(groups[0] ?? {}) + const chosenField = fields.find((f) => f.field === field) ?? fields[0] + const off = !write.allowed || busy + const labelOf = (f: GroupField) => (fields as readonly { field: GroupField; label: string }[]).find((x) => x.field === f)?.label ?? f + + function wrong(value: string, f: GroupField): string | null { + if (value === '') return `Enter a ${kind === 'lists' ? 'list URL' : 'value'}.` + if (kind === 'lists') return isUrl(value) ? null : 'Enter a URL starting with http://, https:// or file://.' + const regex = f === 'blockedRegex' || f === 'allowedRegex' + return !regex && WITH_WHITESPACE.test(value) ? 'A domain has no spaces.' : null + } + + async function run(change: (r: Json) => Json | null, done: string) { + setBusy(true) + const r = await changeAppConfig(token, appName, node, change) + setBusy(false) + if (!r.ok) { + onNotice(r.notice) + return false + } + onNotice({ type: 'success', title: 'App Config Saved!', text: done }) + return true + } + + async function add() { + const value = text.trim() + const bad = wrong(value, chosenField.field) + if (bad != null) return setProblem(bad) + const g = groups.find((x) => groupName(x) === chosen) + if (g != null && entriesOf(g, chosenField.field).some((e) => entryText(e) === value)) { + return setProblem(`This ${noun} is already in the group.`) + } + const ok = await run((r) => addEntry(r, chosen, chosenField.field, value), `Added to the group ${chosen} of ${appName}.`) + if (ok) { + setText('') + setProblem(null) + } + } + + async function save(row: Row) { + const value = (editing?.value ?? '').trim() + const bad = wrong(value, row.field) + if (bad != null) return setProblem(bad) + const ok = await run((r) => (locate(r, row) < 0 ? null : editEntry(r, row.group, row.field, locate(r, row), value)), `Changed in the group ${row.group} of ${appName}.`) + if (ok) { + setEditing(null) + setProblem(null) + } + } + + const q = query.trim().toLowerCase() + const shown = q === '' ? rows : rows.filter((r) => r.value.toLowerCase().includes(q) || r.group.toLowerCase().includes(q)) + const current = pageOf(shown, page) + + return ( + + +
+ + {groups.length === 0 ? ( +

The app has no group yet. Add one in its config in Apps.

+ ) : ( +
+ + {(id) => ( + + )} + + + {(id) => ( + + )} + + + {(id) => ( + { + setText(e.target.value) + setProblem(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void add() + }} + /> + )} + + void add()}> + {kind === 'lists' ? 'Add list' : 'Add rule'} + + {problem != null &&

{problem}

} +
+ )} +
+ + {rows.length > 0 && ( +
+ { + setQuery(e.target.value) + setPage(1) + }} + /> +
+ )} + + data={current.rows} + getRowId={(r) => r.key} + emptyText={rows.length === 0 ? `No ${APP_NAME} ${kind}` : `No ${kind} match this filter`} + className={shared.inPanel} + columns={[ + { id: 'group', header: 'Group', cell: (r) => {r.group || 'Unnamed'}{r.off && <> Off} }, + { + id: 'type', + header: 'Type', + cell: (r) => { + const f = (fields as readonly { field: GroupField; kind: string }[]).find((x) => x.field === r.field) + return ( + + + {labelOf(r.field)} + + ) + }, + }, + { + id: 'value', + header: kind === 'lists' ? 'List' : 'Rule', + cell: (r) => + editing?.key === r.key ? ( + { + setEditing({ key: r.key, value: e.target.value }) + setProblem(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void save(r) + if (e.key === 'Escape') setEditing(null) + }} + /> + ) : ( + kind === 'lists' ? : {r.value} + ), + }, + { + id: 'actions', + td: () => ({ className: shared.entryActions }), + cell: (r) => + editing?.key === r.key ? ( + + + + + ) : ( + + { setEditing({ key: r.key, value: r.value }); setProblem(null) }}> + Edit + + void run((root2) => (locate(root2, r) < 0 ? null : removeEntry(root2, r.group, r.field, locate(root2, r))), `Removed from the group ${r.group} of ${appName}.`)}> + Remove + + + ), + }, + ]} + /> + {current.totalPages > 1 && ( + + )} +
+ ) +} diff --git a/src/screens/blocking/AdvancedBlockingPanel.tsx b/src/screens/blocking/AdvancedBlockingPanel.tsx new file mode 100644 index 0000000..24cf391 --- /dev/null +++ b/src/screens/blocking/AdvancedBlockingPanel.tsx @@ -0,0 +1,114 @@ +import { Panel, Body } from '../../ui/Panel' +import { DataTable } from '../../ui/DataTable' +import { Empty, Failure } from '../../ui/Empty' +import { Button } from '../../ui/Button' +import { RouteLink } from '../../ui/RouteLink' +import { Tag } from '../../ui/Tag' +import { Skeleton } from '@/components/ui/skeleton' +import { APP_NAME, summarize, type GroupSummary } from './advanced-blocking' +import type { AppState } from './advanced-blocking-state' +import shared from './Blocking.module.css' +import styles from './Overview.module.css' + +/* +The Advanced Blocking app at a glance, on the Overview. OURS: the console only knew the +built-in blocking, and a server that blocks with the app showed none of it. It is drawn +only when the app is installed on the server in view, and says: whether the app is on, +each group with its own switch, how much each holds (list URLs of the four kinds, rules +typed in by hand), and the networks that map to it. The lists and the rules themselves +are in the Lists and Rules tabs, labelled per group; the app's own settings (the TTL, the +groups' other options) stay in the Apps section, which the link opens. +*/ + +const sum = (g: GroupSummary, ...fields: (keyof GroupSummary['counts'])[]) => + fields.reduce((n, f) => n + g.counts[f], 0) + +export function AdvancedBlockingPanel({ ab: { state, retry } }: { ab: { state: AppState; retry: () => void } }) { + /* Not installed, not readable, or no single server (hub, "All servers"): nothing to say. */ + if (state.kind === 'none') return null + + const link = Open Advanced Blocking in Apps + + if (state.kind === 'loading') { + return ( + + +
+ + +
+ +
+ ) + } + + if (state.kind === 'failed') { + return ( + + + + Could not read the {APP_NAME} config. {state.message}{' '} + + + + + ) + } + + const summary = summarize(state.root) + + return ( + + {summary.enabled ? 'Enabled' : 'Disabled'} + {link} + + } + > + {summary.groups.length === 0 ? ( + + {link}}> + The app has no group yet, so it blocks nothing. Add one in its config. + + + ) : ( + + data={summary.groups} + getRowId={(g) => g.name} + className={shared.inPanel} + columns={[ + { + id: 'group', + header: 'Group', + cell: (g) => ( + + {g.name || 'Unnamed'} + {g.enabled ? 'Blocking' : 'Off'} + + ), + }, + { id: 'block', header: 'Block lists', cell: (g) => g.counts.blockListUrls }, + { id: 'allow', header: 'Allow lists', cell: (g) => g.counts.allowListUrls }, + { id: 'regex', header: 'Regex lists', cell: (g) => sum(g, 'regexBlockListUrls', 'regexAllowListUrls') }, + { id: 'adblock', header: 'Adblock lists', cell: (g) => g.counts.adblockListUrls }, + { id: 'blocked', header: 'Blocked domains', cell: (g) => g.counts.blocked }, + { id: 'allowed', header: 'Allowed domains', cell: (g) => g.counts.allowed }, + { id: 'rules', header: 'Regex rules', cell: (g) => sum(g, 'blockedRegex', 'allowedRegex') }, + { + id: 'networks', + header: 'Networks', + cell: (g) => { + const all = [...g.networks, ...g.endpoints.map((e) => `${e} (local)`)] + return all.length === 0 ? None : {all.join(', ')} + }, + }, + ]} + /> + )} + + ) +} diff --git a/src/screens/blocking/AllServers.app.test.tsx b/src/screens/blocking/AllServers.app.test.tsx new file mode 100644 index 0000000..e62f2a3 --- /dev/null +++ b/src/screens/blocking/AllServers.app.test.tsx @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import * as apps from '../../api/apps' +import * as settings from '../../api/settings' +import * as dashboard from '../../api/dashboard' +import * as appLists from '../../api/app-lists' +import * as client from '../../api/client' +import { SlotProvider } from '../../app/ChromeSlot' +import { Shell, type ShellSession } from '../../app/Shell' +import { ThemeProvider } from '../../theme/ThemeProvider' +import { forgetRoot } from '../../app/route' +import { HubMode, AGGREGATE } from '../../ui/ClusterNodeSelect' +import { HubNode } from '../../lib/hub-write' +import { Overview } from './Overview' +import { StatusPanel } from './StatusPanel' +import { AB_CONFIG } from './advanced-blocking.fixture' +import { appServersLine, hubMark, summariseServers, type ServerBlocking } from './blocking-state' +import type { Json } from './advanced-blocking' + +/* +"All servers" behind a hub: blocking is judged server by server, the Advanced Blocking app counted +(`blocking-state.ts`, `useAllServersBlocking`), and the Block List Domains figure adds the app lists +of every server. +*/ + +vi.mock('../dashboard/Chart', () => ({ Chart: ({ aria }: { aria: string }) =>
})) + +const row = (server: string, method: ServerBlocking['method'], installed = false, blocking = false): ServerBlocking => ({ + server, method, appInstalled: installed, appBlocking: blocking, +}) + +describe('summariseServers and hubMark', () => { + it('says nothing new when every server blocks with the built-in blocking', () => { + const rows = [row('mdns', 'builtin'), row('ndns', 'builtin')] + expect(summariseServers(rows)).toBeNull() + expect(hubMark(rows, undefined)).toBeNull() + }) + + it('every server blocks by the app only', () => { + const rows = [row('mdns', 'app', true, true), row('ndns', 'app', true, true)] + expect(summariseServers(rows)).toEqual({ title: 'Blocking by Advanced Blocking', sub: 'The built-in blocking is off on every server.', active: true }) + expect(hubMark(rows, undefined)).toBeNull() + }) + + it('names which servers use which when mixed', () => { + const rows = [row('mdns', 'builtin'), row('ndns', 'app', true, true), row('xdns', 'both', true, true)] + expect(summariseServers(rows)).toEqual({ + title: 'Blocking is enabled', + sub: 'Built-in on mdns; Advanced Blocking on ndns; Built-in and Advanced Blocking on xdns.', + active: true, + }) + }) + + it('names the server that blocks by nothing, in the block and in the mark', () => { + const rows = [row('mdns', 'builtin'), row('ndns', 'nothing', true, false)] + expect(summariseServers(rows)).toEqual({ title: 'Blocking is off on ndns', sub: 'Built-in on mdns.', active: false }) + expect(hubMark(rows, undefined)).toEqual({ text: 'Off', label: 'Blocking, disabled on ndns', tone: 'neutral' }) + }) + + it('a pause is named too, and no server at all blocking keeps the single wording', () => { + expect(hubMark([row('mdns', 'builtin'), row('ndns', 'paused')], undefined)?.label).toBe('Blocking, paused on ndns') + expect(summariseServers([row('mdns', 'nothing'), row('ndns', 'nothing')])).toBeNull() + }) + + it('a server that could not be read is not guessed at', () => { + expect(summariseServers([row('mdns', 'app', true, true), row('ndns', 'unknown')])).toBeNull() + expect(hubMark([row('mdns', 'builtin'), row('ndns', 'unknown')], undefined)).toBeNull() + }) + + it('the app line lists where it is installed and where it is idle', () => { + expect(appServersLine([row('mdns', 'app', true, true), row('ndns', 'builtin', true, false), row('x', 'builtin')])).toBe( + 'Advanced Blocking: on mdns, ndns (not blocking on ndns). Choose a server for details.', + ) + expect(appServersLine([row('mdns', 'builtin')])).toBeNull() + }) +}) + +/* Each server's settings and app config, by node ("cluster", the aggregate, is answered by the first server, as the hub does). */ +function servers(spec: Record) { + const of = (node: string) => spec[node] ?? Object.values(spec)[0] + vi.spyOn(settings, 'getSettings').mockImplementation((async (_t: unknown, node: string) => ({ enableBlocking: of(node).builtIn })) as never) + vi.spyOn(apps, 'listApps').mockImplementation((async (_t: unknown, node: string) => ({ + kind: 'ok', + data: { response: { apps: of(node).app == null ? [] : [{ name: 'Advanced Blocking', description: '', version: '1', dnsApps: [{ classPath: 'AdvancedBlocking.App' }] }] } }, + })) as never) + vi.spyOn(apps, 'getAppConfig').mockImplementation((async (_t: unknown, _n: string, node: string) => ({ + kind: 'ok', + data: { response: { config: JSON.stringify(of(node).app) } }, + })) as never) +} + +const SERVERS = ['mdns', 'ndns'] +const status = () => + render( + + + {}} servers={SERVERS} /> + + , + ) + +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() +}) + +describe('StatusPanel with All servers chosen', () => { + it('every server blocks only by the app: not "disabled"', async () => { + servers({ mdns: { builtIn: false, app: AB_CONFIG }, ndns: { builtIn: false, app: AB_CONFIG } }) + status() + expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument() + expect(screen.getByText('The built-in blocking is off on every server.')).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.queryByText('Nothing is being blocked.')).not.toBeInTheDocument() + expect(screen.getByText('Advanced Blocking: on mdns, ndns. Choose a server for details.')).toBeInTheDocument() + }) + + it('mixed: names which server uses which', async () => { + servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: AB_CONFIG } }) + status() + expect(await screen.findByText('Built-in on mdns; Advanced Blocking on ndns.')).toBeInTheDocument() + expect(screen.getByText('Blocking is enabled')).toBeInTheDocument() + expect(screen.getByText('Advanced Blocking: on ndns. Choose a server for details.')).toBeInTheDocument() + }) + + it('a server that blocks by nothing is named', async () => { + servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: { ...AB_CONFIG, enableBlocking: false } } }) + status() + expect(await screen.findByText('Blocking is off on ndns')).toBeInTheDocument() + expect(screen.queryByText('Active')).not.toBeInTheDocument() + expect(screen.getByText(/not blocking on ndns/)).toBeInTheDocument() + }) + + it('asks each server with its own node', async () => { + servers({ mdns: { builtIn: false, app: AB_CONFIG }, ndns: { builtIn: false, app: AB_CONFIG } }) + status() + await screen.findByText('Blocking by Advanced Blocking') + expect(vi.mocked(apps.getAppConfig).mock.calls.map((c) => c[2]).sort()).toEqual(['mdns', 'ndns']) + }) +}) + +describe('Overview, All servers: Block List Domains', () => { + const STATS = { + totalQueries: 5000, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0, + totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 100, totalDropped: 0, + totalClients: 1, zones: 5, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 1000, + } + const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] } + const tile = (label: string) => screen.getAllByText(label).find((el) => /_k_/.test(el.className))!.parentElement! + + function mount() { + servers({ mdns: { builtIn: false, app: AB_CONFIG }, ndns: { builtIn: false, app: AB_CONFIG } }) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ + kind: 'ok', + data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] }, + } as never) + return render( + + + {(ref) => ( + <> +
+ ({ name, type: 'Primary' }))} clusterInitialised /> + + )} + + , + ) + } + + it('adds the app lists of every server, once per server', async () => { + const count = vi.spyOn(appLists, 'getAppListCounts').mockImplementation((async (_t: unknown, _n: string, node: string) => ({ + kind: 'ok', + data: { response: { lists: [{ url: 'https://l.example/shared', count: node === 'mdns' ? 10 : 20 }], total: 0 } }, + })) as never) + mount() + await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,030')).toBeInTheDocument()) + expect(within(tile('Block List Domains')).getByText('1,000 built-in, 30 Advanced Blocking')).toBeInTheDocument() + expect(count.mock.calls.map((c) => c[2]).sort()).toEqual(['mdns', 'ndns']) + }) + + it('a list that was not counted makes it "at least", and says so', async () => { + vi.spyOn(appLists, 'getAppListCounts').mockImplementation((async (_t: unknown, _n: string, node: string) => ({ + kind: 'ok', + data: { response: { lists: [{ url: 'https://l.example/x', count: node === 'mdns' ? 10 : null, error: 'timed out' }], total: 10 } }, + })) as never) + mount() + await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,010+')).toBeInTheDocument()) + expect(within(tile('Block List Domains')).getByText(/1 list not counted/)).toBeInTheDocument() + }) + + it('servers without lists add nothing and the tile stays the built-in figure', async () => { + vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({ kind: 'ok', data: { response: { lists: [], total: 0 } } } as never) + mount() + await screen.findByText('5,000') + await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,000')).toBeInTheDocument()) + expect(within(tile('Block List Domains')).queryByText(/Advanced Blocking/)).not.toBeInTheDocument() + }) +}) + +describe('Shell behind a hub', () => { + beforeEach(() => { + localStorage.clear() + forgetRoot() + window.history.replaceState(null, '', '/about/') + vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never) + }) + + function session(): ShellSession { + const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs'] + return { + token: 'tok', + displayName: 'Administrator', + username: 'admin', + info: { + version: '1.0.0', + uptimestamp: '2026-10-15T08:00:00Z', + dnsServerDomain: 'hub', + hub: true, + clusterNodes: SERVERS.map((name) => ({ name, type: 'Primary' })), + permissions: Object.fromEntries(all.map((s) => [s, { canView: true, canModify: true, canDelete: true }])), + }, + } as never + } + + const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ }) + const mount = () => render( {}} />) + + it('no mark when every server blocks, one way or the other', async () => { + servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: AB_CONFIG } }) + mount() + await vi.waitFor(() => expect(vi.mocked(apps.getAppConfig)).toHaveBeenCalled()) + await new Promise((r) => setTimeout(r, 50)) + expect(entry()).toHaveAccessibleName('Blocking') + }) + + it('Off, naming the server that blocks by nothing', async () => { + servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: null } }) + mount() + await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled on ndns')) + expect(entry()).toHaveTextContent('Off') + }) +}) diff --git a/src/screens/blocking/BlockLists.tsx b/src/screens/blocking/BlockLists.tsx index 99b9533..e0bef66 100644 --- a/src/screens/blocking/BlockLists.tsx +++ b/src/screens/blocking/BlockLists.tsx @@ -26,6 +26,7 @@ import { type ListLine, } from './list-lines' import { Locked } from './Locked' +import { AdvancedBlockingEntries } from './AdvancedBlockingEntries' import { hostsOf, moreLists } from './extra-lists' import { missing, switchNeed, type Permissions } from './permissions' import shared from './Blocking.module.css' @@ -60,6 +61,8 @@ every alert of the update and the save. Everything else is OURS: `removed`; `list-lines.ts`, WHAT CHANGED), `Discard`, and `Save`, shortened from upstream's "Save Settings" (index.html:2460); and the Lists tab's mark while there are unsaved changes, read as `unsaved changes` (`onDirty`, `Blocking.tsx`); +- the Advanced Blocking panel under the table (`AdvancedBlockingEntries.tsx`): the app's own + list URLs by group, when the app is installed. Saved at once, not with this tab's Save; - the two `Could not read …` sentences and the link to Settings › Blocking; - Quick Add's search (`Search lists`, `No lists match`) and its second catalogue, the console's own (`extra-lists.ts`), under `More lists` after Technitium's. @@ -200,6 +203,7 @@ function HeadingEditor({ export function BlockLists({ tabs, + actions, token, permissions, clusterInitialised = false, @@ -207,6 +211,7 @@ export function BlockLists({ allServers = false, }: { tabs?: ReactNode + actions?: ReactNode token: string | null permissions: Permissions clusterInitialised?: boolean @@ -370,7 +375,7 @@ export function BlockLists({ if (viewNeed != null) { return ( <> - + ) @@ -583,7 +588,7 @@ export function BlockLists({ return ( <> - + setNotice(null)} />
@@ -875,6 +880,8 @@ export function BlockLists({
+ + {dirty && (
Unsaved changes to the block list URLs: {changes} diff --git a/src/screens/blocking/Blocking.app.test.tsx b/src/screens/blocking/Blocking.app.test.tsx new file mode 100644 index 0000000..de65e68 --- /dev/null +++ b/src/screens/blocking/Blocking.app.test.tsx @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import * as blocking from '../../api/blocking' +import * as settings from '../../api/settings' +import * as dashboard from '../../api/dashboard' +import * as quick from '../../lib/quick-lists' +import { BlockLists } from './BlockLists' +import { Rules } from './Rules' +import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture' + +/* The Lists and Rules tabs carry the Advanced Blocking app's own entries under the built-in ones. */ + +afterEach(() => vi.restoreAllMocks()) + +function builtIn() { + vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'ok', data: ['own.example'] }) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' }) + vi.spyOn(quick, 'loadQuickList').mockResolvedValue([]) + vi.spyOn(settings, 'readSettings').mockResolvedValue({ + kind: 'ok', + data: { blockListUrls: ['https://built-in.example/list'], blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: null } as never, + }) +} + +describe('Lists tab', () => { + it('shows the app lists by group under the built-in ones', async () => { + builtIn() + serveAdvancedBlocking() + render() + expect(await screen.findByText('https://built-in.example/list')).toBeInTheDocument() + expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument() + expect(screen.getByText('Advanced Blocking lists')).toBeInTheDocument() + }) + + it('has no Advanced Blocking panel without the app', async () => { + builtIn() + serveAdvancedBlocking(AB_CONFIG, false) + render() + expect(await screen.findByText('https://built-in.example/list')).toBeInTheDocument() + expect(screen.queryByText('Advanced Blocking lists')).not.toBeInTheDocument() + }) +}) + +describe('Rules tab', () => { + it('shows the app rules by group under the built-in ones', async () => { + builtIn() + serveAdvancedBlocking() + render() + expect(await screen.findByText('ads.example.com')).toBeInTheDocument() + expect(screen.getByText('Advanced Blocking rules')).toBeInTheDocument() + }) +}) diff --git a/src/screens/blocking/Blocking.layout.test.tsx b/src/screens/blocking/Blocking.layout.test.tsx new file mode 100644 index 0000000..3743cb6 --- /dev/null +++ b/src/screens/blocking/Blocking.layout.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import { SlotProvider } from '../../app/ChromeSlot' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { HubWritable } from '../../lib/hub-write' +import { Blocking } from './Blocking' +import { SectionHeader } from '../../ui/SectionHeader' + +/* +Where the Blocking header's pieces go, as the Dashboard's: the server selector up in the chrome +slot (once), the comparison of the servers among the header's actions, the tabs under the +header, and the hub's "choose a server" line under it all. +*/ + +vi.mock('./Overview', () => ({ Overview: () =>

overview

})) +vi.mock('./BlockLists', () => ({ BlockLists: () =>

lists

})) +vi.mock('../settings/SettingsCompare', () => ({ CompareServersButton: () => })) +vi.mock('./Rules', () => ({ + Rules: ({ tabs, actions }: { tabs?: React.ReactNode; actions?: React.ReactNode }) => ( + + ), +})) +vi.mock('../../api/settings', () => ({ getSettings: async () => ({ enableBlocking: true }) })) +vi.mock('../../api/dashboard', () => ({ getDashboardStats: async () => ({ kind: 'error', message: 'x' }) })) + +afterEach(() => window.history.replaceState(null, '', '/')) + +const NODES = [{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }] +const ADMIN = { Settings: { canView: true, canModify: true, canDelete: true } } + +function mount(writable: string[]) { + return render( + + + + {(ref) => ( + <> +
+
+ {}} permissions={ADMIN as never} nodes={NODES} clusterInitialised /> +
+ + )} + + + , + ) +} + +describe('Blocking header behind a hub', () => { + it('puts the one selector in the chrome slot, not in the page', () => { + mount(['a', 'b']) + expect(within(screen.getByTestId('slot')).getAllByText('Server')).toHaveLength(1) + expect(within(screen.getByTestId('page')).queryByText('Server')).not.toBeInTheDocument() + }) + + it('puts Compare servers among the header actions and the tabs under the header', () => { + mount(['a', 'b']) + const page = within(screen.getByTestId('page')) + expect(page.getByRole('button', { name: 'Compare servers' })).toBeInTheDocument() + expect(page.getByRole('link', { name: 'Overview' })).toBeInTheDocument() + const heading = page.getByRole('heading', { name: 'Blocking' }) + // The comparison is after the title, in the same row; the tabs come after both. + expect(heading.compareDocumentPosition(page.getByRole('button', { name: 'Compare servers' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(page.getByRole('button', { name: 'Compare servers' }).compareDocumentPosition(page.getByRole('link', { name: 'Overview' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('says why the writes are off under the tabs, not above the title', () => { + mount([]) + const page = screen.getByTestId('page') + const hint = within(page).getByRole('status', { name: '' }) + expect(hint).toHaveTextContent('No admin token configured on the hub') + const tabs = within(page).getByRole('link', { name: 'Overview' }) + expect(tabs.compareDocumentPosition(hint) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) +}) diff --git a/src/screens/blocking/Blocking.module.css b/src/screens/blocking/Blocking.module.css index 072dfdb..a82a1b7 100644 --- a/src/screens/blocking/Blocking.module.css +++ b/src/screens/blocking/Blocking.module.css @@ -1,10 +1,9 @@ /* What the three Blocking tabs share. Every value is a token of theme/tokens.css. */ -.stack { display: flex; flex-direction: column; gap: var(--gap-block); } -.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-9); } -.row21 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-9); } -.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s-9); } +.stack { display: flex; flex-direction: column; gap: var(--s-8); } +.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-8); } +.row21 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-8); } /* Lists: the third card carries a date and a button, so its column never goes below what they need on one line; with room to spare the three are equal. */ .kpis3 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(max-content, 1fr); gap: var(--s-9); } @@ -53,7 +52,6 @@ second frame, with rounded corners showing under the panel's header. Written und the text; two by two they keep it. Lists keeps its two counts side by side and gives the date and its button the whole width under them. */ @media (max-width: 1180px) { - .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .kpis3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .kpis3 > :last-child { grid-column: 1 / -1; } } @@ -86,3 +84,12 @@ second frame, with rounded corners showing under the panel's header. Written und /* The canvas's state strip above the tabs. */ .banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); } .bannerAct { margin-left: auto; } + +/* The Advanced Blocking panels of the Lists and Rules tabs: the add row, the filter, a value and its buttons. */ +.entryAdd { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s-5); } +.entryAdd > :nth-child(3) { flex: 1 1 16rem; min-width: 0; } +.entryAdd > p { flex-basis: 100%; margin: 0; } +.entryFilter { padding: 0 var(--s-9) var(--s-5); } +.entryValue { font-family: var(--font-mono); overflow-wrap: break-word; } +.entryActions { text-align: right; white-space: nowrap; } +.dim { color: var(--mute); } diff --git a/src/screens/blocking/Blocking.tsx b/src/screens/blocking/Blocking.tsx index 82a4edd..52be0f9 100644 --- a/src/screens/blocking/Blocking.tsx +++ b/src/screens/blocking/Blocking.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useState } from 'react' import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' +import { InChrome } from '../../app/ChromeSlot' import { CompareServersButton } from '../settings/SettingsCompare' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' @@ -76,14 +77,17 @@ function Banner({ token, permissions, node }: { token: string | null; permission return (
{state.kind === 'on' && Enabled} + {state.kind === 'app' && Advanced Blocking} {state.kind === 'paused' && {untilText(state.till)}} {state.kind === 'off' && Disabled} {state.kind === 'on' ? 'Blocking is applied to every query' - : state.kind === 'paused' - ? 'Blocking is paused and resumes automatically' - : 'Nothing is being blocked'} + : state.kind === 'app' + ? 'The built-in blocking is off; the Advanced Blocking app is blocking' + : state.kind === 'paused' + ? 'Blocking is paused and resumes automatically' + : 'Nothing is being blocked'} {canModify && ( @@ -151,18 +155,18 @@ export function Blocking({ const counts = useCounts(token, permissions, hubNode) + /* The header's actions, where the Dashboard puts its Refresh: the comparison of the servers. */ + const actions = + hub && permissions?.Settings?.canModify === true ? : undefined + const tabs = ( <> - {hub && permissions?.Settings?.canModify === true && ( -
- -
- )} + {/* The server selector goes up into the chrome slot, as the Dashboard's does. Overview + keeps its own there (it also offers "All servers"); these two tabs share this one. */} {hub && active !== 'Overview' && ( - <> + - - + )} {active !== 'Overview' && } + {hub && active !== 'Overview' && } ) if (active === 'Rules') { return ( - + ) } @@ -189,6 +194,7 @@ export function Blocking({ ({ Chart: ({ aria }: { aria: string }) =>
})) + +const STATS = { + totalQueries: 5000, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0, + totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 100, totalDropped: 0, + totalClients: 1, zones: 5, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 1000, +} +const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] } +const OK = { + kind: 'ok' as const, + data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] }, +} + +const text = (lines: string[]) => lines.join('\n') +const LISTS: Record = { + 'https://lists.example/hosts': text(['0.0.0.0 a.example', '0.0.0.0 b.example']), + 'https://lists.example/more': text(['c.example', 'd.example', 'e.example']), + 'https://lists.example/easy': text(['||f.example^']), +} + +function fetchLists(fail: string[] = []) { + return vi.spyOn(globalThis, 'fetch').mockImplementation((async (input: string) => { + if (fail.includes(String(input))) throw new TypeError('Failed to fetch') + const body = LISTS[String(input)] + return body === undefined ? new Response('', { status: 404 }) : new Response(body) + }) as never) +} + +function mount() { + vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK) + return render({(ref) => <>
}) +} + +/* The list downloads among the calls fetch got (the client's own API calls go through it too). */ +const downloaded = (f: { mock: { calls: unknown[][] } }) => f.mock.calls.map((c) => String(c[0])).filter((u) => u.startsWith('https://lists.example')) + +const tile = (label: string) => screen.getAllByText(label).find((el) => /_k_/.test(el.className))!.parentElement! + +beforeEach(() => forgetListCounts()) +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() +}) + +describe('Overview with Advanced Blocking', () => { + it('summarises the app: its switch, each group with its state, its counts and its networks', async () => { + serveAdvancedBlocking() + fetchLists() + mount() + const panel = (await screen.findByRole('heading', { name: 'Advanced Blocking', level: 2 })).closest('[data-slot=frame]') as HTMLElement + const w = within(panel) + expect(await w.findByText('Enabled')).toBeInTheDocument() + const rows = await w.findAllByRole('row') + const everyone = rows.find((r) => within(r).queryByText('everyone'))! + expect(within(everyone).getByText('Blocking')).toBeInTheDocument() + const cells = within(everyone).getAllByRole('cell').map((c) => c.textContent) + // Group, block lists, allow lists, regex lists, adblock lists, blocked, allowed, regex rules, networks. + expect(cells.slice(1)).toEqual(['2', '1', '0', '1', '1', '1', '1', '0.0.0.0/0']) + const family = rows.find((r) => within(r).queryByText('family'))! + expect(within(family).getByText('Off')).toBeInTheDocument() + expect(within(family).getByText('192.168.10.0/24')).toBeInTheDocument() + expect(w.getByRole('link', { name: 'Open Advanced Blocking in Apps' })).toBeInTheDocument() + }) + + it('has no panel when the app is not installed', async () => { + serveAdvancedBlocking(AB_CONFIG, false) + mount() + await screen.findByText('5,000') + expect(screen.queryByRole('heading', { name: 'Advanced Blocking', level: 2 })).not.toBeInTheDocument() + }) + + it('the tile shows the built-in count at once, then adds the app lists downloaded by the page', async () => { + serveAdvancedBlocking() + fetchLists() + mount() + expect(await screen.findByText('5,000')).toBeInTheDocument() + await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,006')).toBeInTheDocument()) + expect(within(tile('Block List Domains')).getByText('1,000 built-in, 6 Advanced Blocking')).toBeInTheDocument() + }) + + it('a list the page cannot read (CORS) makes the figure "at least" and is said, not counted as zero', async () => { + serveAdvancedBlocking() + fetchLists(['https://lists.example/more']) + mount() + await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,003+')).toBeInTheDocument()) + expect(within(tile('Block List Domains')).getByText(/1,000 built-in, 3\+ Advanced Blocking \(1 list not counted\)/)).toBeInTheDocument() + }) + + it('without the app the tile is the built-in figure alone', async () => { + serveAdvancedBlocking(AB_CONFIG, false) + const fetched = fetchLists() + mount() + expect(await screen.findByText('5,000')).toBeInTheDocument() + expect(within(tile('Block List Domains')).queryByText(/Advanced Blocking/)).not.toBeInTheDocument() + expect(downloaded(fetched)).toEqual([]) + }) + + it('behind a hub the hub counts the lists', async () => { + serveAdvancedBlocking() + const fetched = fetchLists() + const hubCount = vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({ + kind: 'ok', + data: { response: { lists: [{ url: 'https://lists.example/hosts', count: 40 }, { url: 'https://lists.example/more', count: null, error: 'timed out' }, { url: 'https://lists.example/easy', count: 2 }], total: 42 } }, + } as never) + localStorage.setItem('dashboardClusterNode', 'a') + vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK) + render( + + {(ref) => <>
} + , + ) + await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,042+')).toBeInTheDocument()) + expect(hubCount).toHaveBeenCalledWith('T', 'Advanced Blocking', 'a') + expect(downloaded(fetched)).toEqual([]) + expect(within(tile('Block List Domains')).getByText(/1 list not counted/)).toBeInTheDocument() + await userEvent.hover(within(tile('Block List Domains')).getByText(/1 list not counted/)) + }) + + it('with "All servers" chosen behind a hub there is no panel and the page downloads no list (the hub counts them)', async () => { + serveAdvancedBlocking() + vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({ kind: 'ok', data: { response: { lists: [], total: 0 } } } as never) + const fetched = fetchLists() + vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK) + render( + + {(ref) => <>
} + , + ) + await screen.findByText('5,000') + expect(screen.queryByRole('heading', { name: 'Advanced Blocking', level: 2 })).not.toBeInTheDocument() + expect(downloaded(fetched)).toEqual([]) + }) + + it('shows the Dashboard tiles and one server selector, in the chrome slot', async () => { + serveAdvancedBlocking(AB_CONFIG, false) + const { container } = mount() + await screen.findByText('5,000') + expect(container.querySelectorAll('[data-slot=frame-panel]').length).toBeGreaterThan(0) + expect(screen.queryByRole('group', { name: 'Server' })).not.toBeInTheDocument() + for (const label of ['Total Queries', 'Blocked', 'Block List Domains', 'Your Rules']) expect(tile(label)).toBeInTheDocument() + expect(container.querySelector('polyline')).toBeNull() + }) +}) diff --git a/src/screens/blocking/Overview.module.css b/src/screens/blocking/Overview.module.css index 9716d9c..80e1d68 100644 --- a/src/screens/blocking/Overview.module.css +++ b/src/screens/blocking/Overview.module.css @@ -33,39 +33,12 @@ measures the two ambers as indistinguishable. The sentence and its icon say it. */ .partial { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-5) var(--s-9); color: var(--mute); } -/* -A figure's trend, beside its text and as tall as it (`.kpi` in Blocking.module.css): -the text keeps its width, so its three lines stay level with the other cards', and the -trend takes what is left, up to half the card. Size containment drops the drawing's own -100:32 shape, so a wide screen cannot make the trend, and with it the card, taller. -*/ -.spark { - display: block; - flex: 1 1 0; - align-self: stretch; - min-width: var(--s-12); - max-width: 50%; - height: auto; - contain: size; +/* The KPI strip's cells: the Dashboard's tile (Dashboard.module.css), four to a row while they fit. */ +.tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + padding: 0; } -/* -`--bp-stack`. Two cards a row leave no room beside the text, so the trend drops to the -card's foot and runs to its edges, as the drawing has it: the body's padding -(`--s-9`, ui/Panel) is given back on three sides and the panel's rounded corners clip -it. The two cards with a trend share the first row, so no card in a row has a band -the other lacks. -*/ -@media (max-width: 560px) { - .trended { flex-wrap: wrap; } - .spark { - flex-basis: calc(100% + 2 * var(--s-9)); - max-width: none; - height: var(--s-12); - margin: 0 calc(-1 * var(--s-9)) calc(-1 * var(--s-9)); - } -} -.sparkTotal { color: var(--ch-total); } -.sparkBlocked { color: var(--ch-block); } /* The ring, with the share written in its hole. */ .ring { position: relative; } @@ -82,3 +55,8 @@ floating in it, so it keeps its own height instead, top-aligned. The check panel answer region is empty (`:empty`) until then. */ .checkRow:has([aria-live]:not(:empty)) > :first-child { align-self: start; } + +/* The Advanced Blocking panel: a group's name with its state, and the networks that map to it. */ +.groupCell { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-3); } +.groupName { font-weight: 600; } +.nets { font-family: var(--font-mono); font-size: var(--t-2); overflow-wrap: break-word; } diff --git a/src/screens/blocking/Overview.real.test.tsx b/src/screens/blocking/Overview.real.test.tsx index 30bba03..8c20fd5 100644 --- a/src/screens/blocking/Overview.real.test.tsx +++ b/src/screens/blocking/Overview.real.test.tsx @@ -33,13 +33,11 @@ afterEach(() => { }) /** The value of a figure, found by its label: a bare number can also be a hit count, - and `Blocked` is also the ring's word. A figure is value, sub-line, label. */ + and `Blocked` is also the ring's word. A tile is label, then a line holding the figure. */ function figure(label: string) { - const own = screen - .getAllByText(label, { selector: 'span' }) - .filter((el) => el.parentElement!.children[2] === el) + const own = screen.getAllByText(label).filter((el) => /_k_/.test(el.className)) expect(own).toHaveLength(1) - return own[0].parentElement!.firstElementChild!.textContent + return own[0].nextElementSibling!.firstElementChild!.textContent } async function shows(label: string, value: string) { @@ -55,13 +53,10 @@ describe('Overview against the harness', () => { render() await shows('Total Queries', s.totalQueries.toLocaleString()) expect(figure('Blocked')).toBe(s.totalBlocked.toLocaleString()) - expect(screen.getByText(`${percentage(s.totalBlocked, s.totalQueries)} of total`)).toBeInTheDocument() + expect(screen.getAllByText(percentage(s.totalBlocked, s.totalQueries)).length).toBeGreaterThan(0) expect(figure('Block List Domains')).toBe(s.blockListZones.toLocaleString()) - const rules = screen.getByText('Your Rules', { selector: 'span' }).parentElement! - expect([...rules.children[0].children].map((c) => c.textContent)).toEqual([ - s.blockedZones.toLocaleString(), - s.allowedZones.toLocaleString(), - ]) + expect(figure('Your Rules')).toBe(s.blockedZones.toLocaleString()) + expect(screen.getByText(`blocked · ${s.allowedZones.toLocaleString()} allowed`)).toBeInTheDocument() }) it('a day of traffic: the bars split Total into Allowed and Blocked, minute by minute', async () => { diff --git a/src/screens/blocking/Overview.test.tsx b/src/screens/blocking/Overview.test.tsx index 8fd485a..7176da8 100644 --- a/src/screens/blocking/Overview.test.tsx +++ b/src/screens/blocking/Overview.test.tsx @@ -8,11 +8,12 @@ import * as zonelists from '../../api/zonelists' import { SlotProvider } from '../../app/ChromeSlot' import { Overview, blockingChart } from './Overview' -/** Your Rules: each number with the word under it, in the card's two columns. */ +/** Your Rules: the blocked count as the tile's figure, the allowed one in its context line. */ function rulePairs() { - const text = screen.getByText('Your Rules', { selector: 'span' }).parentElement! - const [numbers, words] = [...text.children].slice(0, 2).map((row) => [...row.children].map((c) => c.textContent)) - return numbers.map((n, i) => [n, words[i]]) + const label = screen.getAllByText('Your Rules').find((el) => /_k_/.test(el.className))! + const line = label.nextElementSibling! + const allowed = /^blocked · (.*) allowed$/.exec(line.children[1].textContent ?? '')![1] + return [[line.children[0].textContent, 'blocked'], [allowed, 'allowed']] } /* Every `data` the charts were handed, by chart: Chart.tsx rebuilds its canvas on a @@ -114,7 +115,7 @@ describe('Overview', () => { expect(await screen.findByText('20,354')).toBeInTheDocument() expect(screen.getByText('5,310')).toBeInTheDocument() /* The ring writes the same share in its hole: the figure is looked for in its card. */ - expect(within(screen.getByText('5,310').parentElement!).getByText('26.09% of total')).toBeInTheDocument() + expect(within(screen.getByText('5,310').parentElement!).getByText('26.09%')).toBeInTheDocument() expect(screen.getByText('74,779')).toBeInTheDocument() expect(rulePairs()).toEqual([['1', 'blocked'], ['1', 'allowed']]) }) @@ -301,7 +302,7 @@ describe('Overview', () => { const flat = { labels: ['a'], datasets: [{ label: 'Total', data: [0] }, { label: 'Blocked', data: [0] }] } serve().mockResolvedValue({ kind: 'ok', data: { ...OK.data, stats: zero, mainChartData: flat } }) render() - expect(await screen.findByText('0% of total')).toBeInTheDocument() + expect(await screen.findByText('0%')).toBeInTheDocument() expect(screen.getAllByText('No queries for this period.').length).toBe(2) expect(screen.queryByRole('img', { name: 'Allowed and blocked queries over time' })).not.toBeInTheDocument() }) diff --git a/src/screens/blocking/Overview.tsx b/src/screens/blocking/Overview.tsx index ec27517..072c591 100644 --- a/src/screens/blocking/Overview.tsx +++ b/src/screens/blocking/Overview.tsx @@ -1,4 +1,8 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { Frame, FramePanel } from '@/components/reui/frame' +import { KpiTile, StatCard } from '../dashboard/Figures' +import { FIGURES, TOTAL, num2 } from '../dashboard/metrics' +import dash from '../dashboard/Dashboard.module.css' import { getDashboardStats, RANGES, RANGE_LABEL, type ChartData, type DashboardStats, type Range, @@ -16,6 +20,9 @@ import { noticeFromFailure, type Notice } from '../../lib/notice' import { StaleData } from '../StaleData' import { AddDomainBar } from './AddDomainBar' import { StatusPanel } from './StatusPanel' +import { AdvancedBlockingPanel } from './AdvancedBlockingPanel' +import { useAdvancedBlocking } from './advanced-blocking-state' +import { useAppListCount, useAllServersListCount, type AppCount } from './app-list-count' import { TopTable } from './TopTable' import { RecentBlocked } from './RecentBlocked' import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive' @@ -71,6 +78,8 @@ export function blockingChart(main: ChartData): ChartData { } } +const BLOCKED = FIGURES.find((m) => m.k === 'totalBlocked')! + const BAR_LEGEND = ['Allowed', 'Blocked'] as const /* @@ -105,67 +114,21 @@ function Bars({ chart, resetOn }: { chart: ChartData; resetOn: unknown }) { ) } -/* A figure's trend, beside it: decorative, so hidden from assistive technology (the - figure says the number). */ -function Spark({ data, tone }: { data: number[]; tone: string }) { - if (data.length < 2) return null - const max = Math.max(...data, 1) - const step = 100 / (data.length - 1) - const points = data.map((v, i) => `${(i * step).toFixed(2)},${(30 - (v / max) * 28).toFixed(2)}`).join(' ') +/* +The line under "Block List Domains" when the Advanced Blocking app has block lists: how the +figure splits, or that the app's part is still being counted or could not be. With lists +that could not be counted the figure is "at least" (a trailing +) and the line says how many. +*/ +function AppMeta({ built, count }: { built: number; count: AppCount | null }) { + if (count == null) return null + if (count.phase === 'loading') return Counting Advanced Blocking lists… + if (count.phase === 'failed') return Advanced Blocking lists could not be counted + const missed = count.lists.filter((l) => l.count == null) return ( - - ) -} - -function Kpi({ - value, - sub, - label, - children, -}: { - /** Two figures side by side (`Your Rules`) come as pairs, each number over its word. */ - value: string | [string, string] - sub?: string | [string, string] - label: string - children?: ReactNode -}) { - const paired = Array.isArray(value) && Array.isArray(sub) - return ( - - -
-
- {paired ? ( - /* Both lines are laid on one two-column grid, so each number stands - over its own word: written as one string, the 7 of "2 · 7" sat over - the middle of "blocked · allowed". */ - <> - - {value[0]} - {value[1]} - - - {sub[0]} - {sub[1]} - - - ) : ( - <> - {value} - {/* A figure without a sub-line keeps an empty one, a full line high, so the four - labels sit on one line as the drawing has them. */} - {sub ?? '\u00a0'} - - )} - {label} -
- {children} -
- -
+ `${l.url}: ${l.error ?? 'not counted'}`).join(', ') || undefined}> + {num2(built)} built-in, {num2(count.total)}{count.uncounted > 0 ? '+' : ''} Advanced Blocking + {count.uncounted > 0 && ` (${count.uncounted} ${count.uncounted === 1 ? 'list' : 'lists'} not counted)`} + ) } @@ -183,6 +146,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading: export function Overview({ tabs, + actions, token, permissions: rolePermissions, nodes = [], @@ -190,6 +154,7 @@ export function Overview({ serverDomain, }: { tabs?: ReactNode + actions?: ReactNode token: string | null permissions: Permissions nodes?: ClusterNode[] @@ -213,6 +178,12 @@ export function Overview({ const allServers = hub && node === AGGREGATE && missing(rolePermissions, 'Blocked.canModify', 'Allowed.canModify') == null const barPermissions = allServers ? rolePermissions : permissions + /* The Advanced Blocking app of the server in view: its panel, and its lists' domains in the figure. */ + const ab = useAdvancedBlocking(token, rolePermissions, hub ? node : '') + const everywhere = hub && node === AGGREGATE + const single = useAppListCount(token, hub ? node : '', ab.state) + const summed = useAllServersListCount(token, everywhere ? nodes.map((n) => n.name) : []) + const appCount = everywhere ? summed : single const [data, setData] = useState(null) /** The first read of this period and node failed: there is nothing to show. */ const [failure, setFailure] = useState(false) @@ -347,21 +318,21 @@ export function Overview({ onChange={setNode} /> - - - + +
({ id: r, label: RANGE_LABEL[r] }))} active={range} onChoose={setRange} /> - } - /> +
+
+ {tabs} + setNotice(null)} />
@@ -370,28 +341,36 @@ export function Overview({ {statsNeed != null ? ( ) : ( -
- - {view && } - - - {view && } - - - -
+ /* The Dashboard's KPI strip: a Frame of tiles, no trends (the chart below has the series). */ + + + + + 0 ? '+' : ''}` : '—'} + noData={!s} + wrap + > + {s ? : ' '} + + + {s ? blocked · {num2(s.allowedZones)} allowed : ' '} + + + )}
- + n.name)} />
+ + {/* Without Dashboard.View every panel fed by the stats keeps its place and its title, locked: the screen does not change shape with who looks. */} diff --git a/src/screens/blocking/Rules.test.tsx b/src/screens/blocking/Rules.test.tsx index 16d9c5d..a11e54f 100644 --- a/src/screens/blocking/Rules.test.tsx +++ b/src/screens/blocking/Rules.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import * as apps from '../../api/apps' import * as blocking from '../../api/blocking' import * as zonelists from '../../api/zonelists' import { Rules } from './Rules' @@ -366,6 +367,8 @@ describe('Rules', () => { to read again, as the blocking state does (StatusPanel). */ it('a failed first read leaves Failure and Retry in the table slot', async () => { const read = vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'error', message: 'Access was denied.' }) + // No app to list: the Advanced Blocking panel has its own Retry, and this test is about the table's. + vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never) draw() expect(await screen.findByText('Could not read the rules.')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Close' })) diff --git a/src/screens/blocking/Rules.tsx b/src/screens/blocking/Rules.tsx index e1b8ac4..6618f63 100644 --- a/src/screens/blocking/Rules.tsx +++ b/src/screens/blocking/Rules.tsx @@ -24,6 +24,7 @@ import { StaleData } from '../StaleData' import { Lists, ImportDomains, type Confirmation } from '../lists/Lists' import { LockedBody, LockedItem } from './Locked' import { AddDomainBar } from './AddDomainBar' +import { AdvancedBlockingEntries } from './AdvancedBlockingEntries' import { missing, type Need, type Permissions } from './permissions' import { joinDeleted, modifyNeed, notRestoredNotice, restoreRules, RESTORED_MS, undoMessage, undoNeed, UNDO_MS, @@ -62,6 +63,9 @@ Without `Modify` on the rule's list the session could not put it back, so that Delete still asks first, with upstream's sentence. Everywhere else the confirmation stays: Flush, the tree (upstream's own screen) and the other sections. +Under the table, when the Advanced Blocking app is installed, its own rules by group (blocked and +allowed domains, blocked and allowed regex): `AdvancedBlockingEntries.tsx`. + `Tree` mounts the tree that exists today, so nothing upstream has is lost. It reports through this page's notifier, and every change (from the table, the foot, the add bar or the tree itself) reads the table again from the primary node and @@ -79,12 +83,14 @@ const LABEL: Record = { blocked: 'Blocked', allowed: 'Allowe export function Rules({ tabs, + actions, token, permissions, nodes = [], clusterInitialised = false, }: { tabs?: ReactNode + actions?: ReactNode token: string | null permissions: Permissions nodes?: { name: string; type: string }[] @@ -379,7 +385,7 @@ export function Rules({ return ( <> - + setNotice(null)} />
@@ -586,6 +592,8 @@ export function Rules({
+ +
{/* The action's promise goes back to `Confirm`, which keeps the dialog busy diff --git a/src/screens/blocking/StatusPanel.app.test.tsx b/src/screens/blocking/StatusPanel.app.test.tsx new file mode 100644 index 0000000..2688087 --- /dev/null +++ b/src/screens/blocking/StatusPanel.app.test.tsx @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import * as settings from '../../api/settings' +import type { DnsSettings } from '../../api/settings' +import { StatusPanel } from './StatusPanel' +import { blockingMark } from './blocking-state' +import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture' + +afterEach(() => { + vi.restoreAllMocks() +}) + +const serve = (s: Partial) => vi.spyOn(settings, 'getSettings').mockResolvedValue(s as DnsSettings) +const mount = (permissions: Record | undefined = undefined) => + render( {}} />) + +describe('StatusPanel with the Advanced Blocking app', () => { + it('built-in off but the app blocking: says so, Active, and still offers Enable', async () => { + serve({ enableBlocking: false }) + serveAdvancedBlocking() + mount() + expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument() + expect(screen.getByText('Active').className).toMatch(/ok/) + expect(screen.getByText(/The built-in blocking is off/)).toBeInTheDocument() + expect(screen.queryByText('Nothing is being blocked.')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Blocking options' })).toHaveTextContent('Enable') + }) + + it('built-in paused and the app blocking: the app wins, and the pause is mentioned', async () => { + serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' }) + serveAdvancedBlocking() + mount() + expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument() + expect(screen.getByText(/The built-in blocking is paused \(until /)).toBeInTheDocument() + }) + + it('the app installed but its switch off: as before, blocking is disabled', async () => { + serve({ enableBlocking: false }) + serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false }) + mount() + expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument() + expect(screen.getByText('Nothing is being blocked.')).toBeInTheDocument() + }) + + it('the app installed with every group off: as before', async () => { + serve({ enableBlocking: false }) + serveAdvancedBlocking({ ...AB_CONFIG, groups: [{ name: 'g', enableBlocking: false }] }) + mount() + expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument() + }) + + it('the app not installed: as before', async () => { + serve({ enableBlocking: false }) + serveAdvancedBlocking(AB_CONFIG, false) + mount() + expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument() + }) + + it('paused with the app not blocking keeps the paused wording', async () => { + serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' }) + serveAdvancedBlocking(AB_CONFIG, false) + mount() + expect(await screen.findByText('Blocking is paused')).toBeInTheDocument() + }) + + it('built-in on: the app is not even asked', async () => { + serve({ enableBlocking: true }) + const { get } = serveAdvancedBlocking() + mount() + expect(await screen.findByText('Blocking is enabled')).toBeInTheDocument() + expect(get).not.toHaveBeenCalled() + }) + + it('without Apps.canView the app is not asked and the block says what the settings say', async () => { + serve({ enableBlocking: false }) + const { get } = serveAdvancedBlocking() + mount({ Apps: { canView: false, canModify: false, canDelete: false } }) + expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument() + expect(get).not.toHaveBeenCalled() + }) +}) + +describe('the sidebar mark', () => { + it('has none while the app blocks in the built-in blocking place', () => { + expect(blockingMark({ kind: 'app' }, undefined)).toBeNull() + expect(blockingMark({ kind: 'app', till: '2099-01-01T00:00:00Z' }, undefined)).toBeNull() + expect(blockingMark({ kind: 'off' }, undefined)?.text).toBe('Off') + }) +}) diff --git a/src/screens/blocking/StatusPanel.tsx b/src/screens/blocking/StatusPanel.tsx index bf81c0e..0068aae 100644 --- a/src/screens/blocking/StatusPanel.tsx +++ b/src/screens/blocking/StatusPanel.tsx @@ -13,7 +13,9 @@ import type { Notice } from '../../lib/notice' import { Locked } from './Locked' import { missing, switchNeed, type Permissions } from './permissions' import { AllServersActions } from './AllServersActions' -import { untilText, useBlockingState } from './blocking-state' +import { appServersLine, summariseServers, untilText, useAllServersBlocking, useBlockingState } from './blocking-state' +import { AGGREGATE } from '../../ui/ClusterNodeSelect' +import { APP_NAME } from './advanced-blocking' import shared from './Blocking.module.css' import styles from './Overview.module.css' @@ -32,6 +34,9 @@ Three things the block owes the rules rather than the menu: - A pause ends on the server by itself, so the block reads again when it should have ended: otherwise it would keep saying "paused" for as long as the screen is open (`blocking-state.ts`, AFTER_PAUSE_MS). +- With the built-in blocking off and the Advanced Blocking app blocking in its place + (`kind: 'app'`), the block says so, with the `Active` pill, and still offers the + built-in blocking's own `Enable`. - The amber of "paused" says "yours to change". Without `Settings.canModify` it is not, and the design rule is that the padlock wins: the mark goes neutral. */ @@ -40,7 +45,10 @@ export function StatusPanel({ token, permissions, onNotice, + servers = [], }: { + /** Behind a hub: every server, for the "All servers" wording. */ + servers?: readonly string[] token: string | null permissions: Permissions onNotice: (n: Notice) => void @@ -56,6 +64,11 @@ export function StatusPanel({ servers" chosen it opens the flow, and the list actions for several servers sit beside it. */ const hub = useContext(HubMode) const all = useContext(HubAll) + /* "All servers": each server's own blocking, the app's included (`blocking-state.ts`). */ + const everywhere = hub && node === AGGREGATE + const rows = useAllServersBlocking(token, permissions, servers, everywhere && viewNeed == null) + const summary = everywhere ? summariseServers(rows) : null + const appLine = everywhere ? appServersLine(rows) : null if (viewNeed != null) return @@ -78,7 +91,10 @@ export function StatusPanel({ const verb = state?.kind === 'on' ? 'Disable' : 'Enable' /* The padlock wins: a pause the user cannot lift is not drawn in amber. */ - const tone = state?.kind === 'paused' && modifyNeed != null ? 'off' : state?.kind + const tone = + summary != null + ? summary.active ? 'on' : 'off' + : state?.kind === 'paused' && modifyNeed != null ? 'off' : state?.kind === 'app' ? 'on' : state?.kind /* Centred: the panel shares its row with the taller add bar and stretches to it. */ return ( @@ -90,20 +106,24 @@ export function StatusPanel({ ) : (
- +
- {state.kind === 'on' + {summary != null + ? summary.title + : state.kind === 'on' ? 'Blocking is enabled' - : state.kind === 'paused' - ? 'Blocking is paused' - : 'Blocking is disabled'} + : state.kind === 'app' + ? `Blocking by ${APP_NAME}` + : state.kind === 'paused' + ? 'Blocking is paused' + : 'Blocking is disabled'} {/* The state as a pill, as the drawing has it. Paused, the amber says "yours to lift": without Settings.canModify it is neutral, because the padlock wins. */} - {state.kind === 'on' && Active} - {state.kind === 'paused' && + {(summary?.active ?? (state.kind === 'on' || state.kind === 'app')) && Active} + {summary == null && state.kind === 'paused' && (modifyNeed == null ? ( {untilText(state.till)} ) : ( @@ -111,11 +131,17 @@ export function StatusPanel({ ))}
- {state.kind === 'paused' - ? 'Resumes automatically.' - : state.kind === 'on' - ? 'Queries matching a block list or your rules are answered as blocked.' - : 'Nothing is being blocked.'} + {summary != null + ? summary.sub + : state.kind === 'app' + ? state.till != null + ? `The built-in blocking is paused (${untilText(state.till).toLowerCase()}); queries are blocked by the app's groups.` + : "The built-in blocking is off; queries are blocked by the app's groups." + : state.kind === 'paused' + ? 'Resumes automatically.' + : state.kind === 'on' + ? 'Queries matching a block list or your rules are answered as blocked.' + : 'Nothing is being blocked.'}
{modifyNeed != null ? ( @@ -125,6 +151,7 @@ export function StatusPanel({ )}
)} + {state != null && appLine != null &&

{appLine}

} {state != null && all !== null && modifyNeed == null && (
diff --git a/src/screens/blocking/advanced-blocking-state.ts b/src/screens/blocking/advanced-blocking-state.ts new file mode 100644 index 0000000..333b634 --- /dev/null +++ b/src/screens/blocking/advanced-blocking-state.ts @@ -0,0 +1,135 @@ +import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import { getAppConfig, listApps, setAppConfig, type InstalledApp } from '../../api/apps' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { noticeFromFailure, type Notice } from '../../lib/notice' +import { findApp, parseConfig, serialize, type Json } from './advanced-blocking' +import { missing, type Permissions } from './permissions' + +/* +Whether this server has the Advanced Blocking app and what its config says. OURS. Four +places read it (the sidebar's mark, the status block, the app's panel and the Lists and +Rules tabs), so it lives here once. + +It reads `apps/list` and then `apps/config/get`, on the node the console is connected to +(behind a hub: the server it names; the hub refuses a config read without one, so with +"All servers" chosen nothing is read and the state is `none`). Without `Apps.canView` +nothing is read either: the server would refuse, and the answer is `none`, as for an app +that is not installed. Reads are numbered, so an older answer cannot overwrite a newer one. + +A write made from this console (`saveAppConfig`) tells the other readers on the page +(`appConfigChanged`), as the settings do (`lib/settings-signal.ts`). +*/ + +export type AppState = + | { kind: 'loading' } + /** Not installed, not readable, or no single server to ask. */ + | { kind: 'none' } + | { kind: 'failed'; message: string } + | { kind: 'ready'; app: InstalledApp; root: Json; text: string | null } + +type Listener = () => void +const listeners = new Set() + +export function onAppConfigChanged(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function appConfigChanged(): void { + for (const l of [...listeners]) l() +} + +/** One read: the installed app and its config, or why there is none. */ +export async function readAppState(token: string | null, node: string): Promise { + const listed = await listApps(token, node) + if (listed.kind !== 'ok') return { kind: 'failed', message: noticeFromFailure(listed).text } + const app = findApp(listed.data.response?.apps) + if (app == null) return { kind: 'none' } + const got = await getAppConfig(token, app.name, node) + if (got.kind !== 'ok') return { kind: 'failed', message: noticeFromFailure(got).text } + const text = got.data.response?.config ?? null + const parsed = parseConfig(text) + if (!parsed.ok) return { kind: 'failed', message: 'The Advanced Blocking config is not valid JSON.' } + return { kind: 'ready', app, root: parsed.root, text } +} + +export function useAdvancedBlocking( + token: string | null, + permissions: Permissions, + /** Behind a hub: the server to ask. Empty elsewhere. */ + node = '', + /** False reads nothing (the app is not wanted, e.g. the built-in blocking is already on). */ + enabled = true, +): { state: AppState; reload: () => void; retry: () => void } { + const hub = useContext(HubMode) + const viewNeed = missing(permissions, 'Apps.canView') + const single = !hub || (node !== '' && node !== 'cluster') + const active = enabled && viewNeed == null && single + const [state, setState] = useState({ kind: 'loading' }) + const asked = useRef(0) + + const reload = useCallback(() => { + if (!active) return + const mine = ++asked.current + void readAppState(token, node).then((s) => { + if (mine === asked.current) setState(s) + }) + }, [active, token, node]) + + useEffect(() => { + if (!active) { + asked.current++ + return + } + setState({ kind: 'loading' }) + reload() + }, [active, reload]) + + useEffect(() => (active ? onAppConfigChanged(reload) : undefined), [active, reload]) + + const retry = useCallback(() => { + setState({ kind: 'loading' }) + reload() + }, [reload]) + + return { state: active ? state : { kind: 'none' }, reload, retry } +} + +/* +Writes the whole config back and tells the page. The caller has changed only the field it +meant to; `serialize` keeps every other key. Resolves to a notice when it failed. +*/ +export async function saveAppConfig( + token: string | null, + name: string, + root: Json, + node = '', +): Promise<{ ok: true } | { ok: false; notice: Notice }> { + const r = await setAppConfig(token, name, serialize(root), node) + if (r.kind !== 'ok') return { ok: false, notice: noticeFromFailure(r) } + appConfigChanged() + return { ok: true } +} + +/* +One change to the app's config, read-modify-write. The config is read again just before +(so an edit made elsewhere since the page loaded is not overwritten with an old copy), +`change` returns the new root or `null` when what it meant to change is gone (a group that +was renamed meanwhile), and the whole text goes back with `apps/config/set`. +*/ +export async function changeAppConfig( + token: string | null, + name: string, + node: string, + change: (root: Json) => Json | null, +): Promise<{ ok: true } | { ok: false; notice: Notice }> { + const got = await getAppConfig(token, name, node) + if (got.kind !== 'ok') return { ok: false, notice: noticeFromFailure(got) } + const parsed = parseConfig(got.data.response?.config ?? null) + if (!parsed.ok) return { ok: false, notice: { type: 'danger', title: 'Error!', text: 'The Advanced Blocking config is not valid JSON.' } } + const next = change(parsed.root) + if (next == null) return { ok: false, notice: { type: 'warning', title: 'Not saved', text: 'That group no longer exists in the Advanced Blocking config.' } } + return saveAppConfig(token, name, next, node) +} diff --git a/src/screens/blocking/advanced-blocking.fixture.ts b/src/screens/blocking/advanced-blocking.fixture.ts new file mode 100644 index 0000000..e63597e --- /dev/null +++ b/src/screens/blocking/advanced-blocking.fixture.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest' +import * as apps from '../../api/apps' +import type { Json } from './advanced-blocking' + +/* A server that has the Advanced Blocking app, with this config. Shared by the tests of the Blocking section. */ +export const AB_CONFIG: Json = { + enableBlocking: true, + blockingAnswerTtl: 30, + futureKey: { keep: ['me'] }, + localEndPointGroupMap: { '127.0.0.1': 'bypass' }, + networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone' }, + groups: [ + { + name: 'everyone', + enableBlocking: true, + blocked: ['ads.example.com'], + allowed: ['good.example.org'], + blockListUrls: ['https://lists.example/hosts', 'https://lists.example/more'], + allowListUrls: ['https://lists.example/allow'], + blockedRegex: ['^ads\\.'], + adblockListUrls: ['https://lists.example/easy'], + }, + { name: 'family', enableBlocking: false, blocked: ['games.example.com'] }, + ], +} + +export function serveAdvancedBlocking(config: Json | null = AB_CONFIG, installed = true) { + const list = installed + ? [{ name: 'Advanced Blocking', description: '', version: '10', dnsApps: [{ classPath: 'AdvancedBlocking.App' }] }] + : [{ name: 'Split Horizon', description: '', version: '1', dnsApps: [{ classPath: 'SplitHorizon.App' }] }] + vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: list } } } as never) + const get = vi.spyOn(apps, 'getAppConfig').mockResolvedValue({ + kind: 'ok', + data: { response: { config: config == null ? null : JSON.stringify(config) } }, + } as never) + const set = vi.spyOn(apps, 'setAppConfig').mockResolvedValue({ kind: 'ok', data: { status: 'ok' } } as never) + return { get, set } +} + +/** The config the last `setAppConfig` was given, parsed. */ +export function written(set: ReturnType['set']): Json { + return JSON.parse(set.mock.calls.at(-1)![2] as string) as Json +} diff --git a/src/screens/blocking/advanced-blocking.test.ts b/src/screens/blocking/advanced-blocking.test.ts new file mode 100644 index 0000000..05cd698 --- /dev/null +++ b/src/screens/blocking/advanced-blocking.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import type { InstalledApp } from '../../api/apps' +import { + addEntry, blocksNow, countableUrls, editEntry, findApp, parseConfig, removeEntry, serialize, summarize, type Json, +} from './advanced-blocking' + +const CONFIG: Json = { + enableBlocking: true, + blockingAnswerTtl: 30, + futureKey: { keep: ['me'] }, + localEndPointGroupMap: { '127.0.0.1': 'bypass' }, + networkGroupMap: { '10.0.0.0/8': 'family', '0.0.0.0/0': 'everyone' }, + groups: [ + { + name: 'everyone', + enableBlocking: true, + blockAsNxDomain: true, + blocked: ['a.example'], + blockListUrls: ['https://x.example/hosts', { url: 'https://x.example/obj', note: 'kept' }], + adblockListUrls: ['https://x.example/easy'], + regexBlockListUrls: ['https://x.example/re'], + blockedRegex: ['^ads'], + }, + { name: 'family', enableBlocking: false, blockListUrls: ['https://x.example/hosts'] }, + ], +} + +const app = (name: string, classPath = 'Other.App'): InstalledApp => ({ + name, description: null, version: '1', dnsApps: [{ classPath } as InstalledApp['dnsApps'][number]], +}) + +describe('findApp', () => { + it('finds the app by its name or by its class', () => { + expect(findApp([app('Split Horizon'), app('Advanced Blocking')])?.name).toBe('Advanced Blocking') + expect(findApp([app('Renamed', 'AdvancedBlocking.App')])?.name).toBe('Renamed') + expect(findApp([app('Split Horizon')])).toBeUndefined() + expect(findApp(undefined)).toBeUndefined() + }) +}) + +describe('parseConfig', () => { + it('reads an object, treats an emptied config as empty, and refuses anything else', () => { + expect(parseConfig('{"enableBlocking":true}')).toEqual({ ok: true, root: { enableBlocking: true } }) + expect(parseConfig(null)).toEqual({ ok: true, root: {} }) + expect(parseConfig(' ')).toEqual({ ok: true, root: {} }) + expect(parseConfig('[1]')).toEqual({ ok: false }) + expect(parseConfig('{nope')).toEqual({ ok: false }) + }) +}) + +describe('blocksNow', () => { + it('needs the app on and at least one group on', () => { + expect(blocksNow(CONFIG)).toBe(true) + expect(blocksNow({ ...CONFIG, enableBlocking: false })).toBe(false) + expect(blocksNow({ ...CONFIG, groups: [{ name: 'g', enableBlocking: false }] })).toBe(false) + expect(blocksNow({ enableBlocking: true })).toBe(false) + expect(blocksNow({})).toBe(false) + }) +}) + +describe('summarize', () => { + it('counts what each group holds and names the networks that map to it', () => { + const s = summarize(CONFIG) + expect(s.enabled).toBe(true) + expect(s.blocking).toBe(true) + expect(s.groups.map((g) => [g.name, g.enabled])).toEqual([['everyone', true], ['family', false]]) + const g = s.groups[0] + expect(g.counts.blockListUrls).toBe(2) + expect(g.counts.adblockListUrls).toBe(1) + expect(g.counts.regexBlockListUrls).toBe(1) + expect(g.counts.allowListUrls).toBe(0) + expect(g.counts.blocked).toBe(1) + expect(g.counts.blockedRegex).toBe(1) + expect(g.networks).toEqual(['0.0.0.0/0']) + expect(s.groups[1].networks).toEqual(['10.0.0.0/8']) + }) + + it('tells a local end point from a network', () => { + const s = summarize({ ...CONFIG, groups: [{ name: 'bypass', enableBlocking: true }] }) + expect(s.groups[0].endpoints).toEqual(['127.0.0.1']) + expect(s.groups[0].networks).toEqual([]) + }) +}) + +describe('countableUrls', () => { + it('lists block and adblock URLs once, strings and objects, http(s) only', () => { + expect(countableUrls({ groups: [...(CONFIG.groups as unknown[]), { name: 'x', blockListUrls: ['file:///l'] }] })).toEqual([ + 'https://x.example/hosts', 'https://x.example/obj', 'https://x.example/easy', + ]) + }) +}) + +describe('changing a group', () => { + it('adds in the form the list already has, and touches nothing else', () => { + const next = addEntry(CONFIG, 'everyone', 'blockListUrls', 'https://x.example/new') + const list = (next.groups as Json[])[0].blockListUrls + expect(list).toEqual(['https://x.example/hosts', { url: 'https://x.example/obj', note: 'kept' }, { url: 'https://x.example/new' }]) + expect(next.futureKey).toEqual({ keep: ['me'] }) + expect((next.groups as Json[])[1]).toEqual((CONFIG.groups as Json[])[1]) + expect(Object.keys(next)).toEqual(Object.keys(CONFIG)) + expect(Object.keys((next.groups as Json[])[0])).toEqual(Object.keys((CONFIG.groups as Json[])[0])) + // The original is not changed. + expect((CONFIG.groups as Json[])[0].blockListUrls).toHaveLength(2) + }) + + it('adds a plain string to a list of strings, and starts a list the group did not have at its end', () => { + const next = addEntry(CONFIG, 'family', 'allowedRegex', '^x$') + expect((next.groups as Json[])[1]).toEqual({ name: 'family', enableBlocking: false, blockListUrls: ['https://x.example/hosts'], allowedRegex: ['^x$'] }) + expect(Object.keys((next.groups as Json[])[1]).at(-1)).toBe('allowedRegex') + expect(((addEntry(CONFIG, 'family', 'blockListUrls', 'https://y.example') as Json).groups as Json[])[1].blockListUrls).toEqual(['https://x.example/hosts', 'https://y.example']) + }) + + it('removes one entry and edits one keeping the other keys of an object entry', () => { + const removed = removeEntry(CONFIG, 'everyone', 'blockListUrls', 0) + expect((removed.groups as Json[])[0].blockListUrls).toEqual([{ url: 'https://x.example/obj', note: 'kept' }]) + const edited = editEntry(CONFIG, 'everyone', 'blockListUrls', 1, 'https://x.example/moved') + expect((edited.groups as Json[])[0].blockListUrls).toEqual(['https://x.example/hosts', { url: 'https://x.example/moved', note: 'kept' }]) + }) + + it('leaves the config alone for a group that is not there', () => { + expect(addEntry(CONFIG, 'gone', 'blocked', 'a.example')).toBe(CONFIG) + }) + + it('writes two-space JSON that reads back the same', () => { + expect(JSON.parse(serialize(CONFIG))).toEqual(CONFIG) + expect(serialize({ a: 1 })).toBe('{\n "a": 1\n}') + }) +}) diff --git a/src/screens/blocking/advanced-blocking.ts b/src/screens/blocking/advanced-blocking.ts new file mode 100644 index 0000000..2b4e86e --- /dev/null +++ b/src/screens/blocking/advanced-blocking.ts @@ -0,0 +1,202 @@ +import type { InstalledApp } from '../../api/apps' + +/* +The Advanced Blocking DNS app, as the Blocking section reads and writes it. OURS: the +section only knew the built-in blocking, and a server that blocks with this app (built-in +switched off) looked unprotected. The app's config is a JSON text that +`apps/config/get` returns and `apps/config/set` takes whole, so this file is the +pure part: finding the app, reading the config, summarising it, and changing ONE field of +ONE group while every other key stays where it was. + +The config (AdvancedBlocking.App, `BlockingApp` config): + + { enableBlocking, blockingAnswerTtl, blockListUrlUpdateIntervalHours, + localEndPointGroupMap: { "127.0.0.1": "bypass" }, + networkGroupMap: { "0.0.0.0/0": "everyone" }, + groups: [ { name, enableBlocking, allowTxtBlockingReport, blockAsNxDomain, + blockingAddresses, allowed, blocked, allowListUrls, blockListUrls, + allowedRegex, blockedRegex, regexAllowListUrls, regexBlockListUrls, + adblockListUrls } ] } + +Any field may be missing, and a list URL may be a plain string or (newer versions) an +object with a `url`. What is read in one form is written back in that form, and keys this +file does not know are never touched. +*/ + +export const APP_NAME = 'Advanced Blocking' +const CLASS_PATH = 'AdvancedBlocking.App' + +export type Json = Record + +/** The five fields of a group that hold list URLs, with the words the console uses for them. */ +export const URL_FIELDS = [ + { field: 'blockListUrls', label: 'Block list', kind: 'block' }, + { field: 'allowListUrls', label: 'Allow list', kind: 'allow' }, + { field: 'regexBlockListUrls', label: 'Regex block list', kind: 'block' }, + { field: 'regexAllowListUrls', label: 'Regex allow list', kind: 'allow' }, + { field: 'adblockListUrls', label: 'Adblock list', kind: 'block' }, +] as const + +/** The four fields of a group that hold rules typed in by hand. */ +export const RULE_FIELDS = [ + { field: 'blocked', label: 'Blocked domain', kind: 'block', regex: false }, + { field: 'allowed', label: 'Allowed domain', kind: 'allow', regex: false }, + { field: 'blockedRegex', label: 'Blocked regex', kind: 'block', regex: true }, + { field: 'allowedRegex', label: 'Allowed regex', kind: 'allow', regex: true }, +] as const + +export type UrlField = (typeof URL_FIELDS)[number]['field'] +export type RuleField = (typeof RULE_FIELDS)[number]['field'] +export type GroupField = UrlField | RuleField + +const isObject = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v) + +/** The installed app, if this server has it (by name, or by the class it carries). */ +export function findApp(apps: readonly InstalledApp[] | undefined): InstalledApp | undefined { + return (apps ?? []).find( + (a) => a.name === APP_NAME || (a.dnsApps ?? []).some((d) => d.classPath === CLASS_PATH), + ) +} + +export type Parsed = { ok: true; root: Json } | { ok: false } + +/** `config: null` (an emptied config) is a config with nothing in it; text that is not an object is unreadable. */ +export function parseConfig(text: string | null | undefined): Parsed { + if (text == null || text.trim() === '') return { ok: true, root: {} } + try { + const value: unknown = JSON.parse(text) + return isObject(value) ? { ok: true, root: value } : { ok: false } + } catch { + return { ok: false } + } +} + +export function serialize(root: Json): string { + return JSON.stringify(root, null, 2) +} + +export function groupsOf(root: Json): Json[] { + return Array.isArray(root.groups) ? root.groups.filter(isObject) : [] +} + +export const groupName = (g: Json): string => (typeof g.name === 'string' ? g.name : '') + +/** A list entry's URL: the string itself, or the `url` of an object. */ +export function entryText(entry: unknown): string { + if (typeof entry === 'string') return entry + if (isObject(entry) && typeof entry.url === 'string') return entry.url + return '' +} + +export function entriesOf(group: Json, field: GroupField): unknown[] { + const v = group[field] + return Array.isArray(v) ? v : [] +} + +/** Whether the app blocks anything: its own switch is on and at least one group's is. */ +export function blocksNow(root: Json): boolean { + return root.enableBlocking === true && groupsOf(root).some((g) => g.enableBlocking === true) +} + +export interface GroupSummary { + name: string + enabled: boolean + /** Entries per field, for every field of URL_FIELDS and RULE_FIELDS. */ + counts: Record + /** The networks (and local end points) that map to this group. */ + networks: string[] + endpoints: string[] +} + +export interface Summary { + enabled: boolean + blocking: boolean + groups: GroupSummary[] +} + +/** The keys of a map whose value is `name`. */ +function mapped(map: unknown, name: string): string[] { + if (!isObject(map)) return [] + return Object.entries(map) + .filter(([, v]) => v === name) + .map(([k]) => k) +} + +export function summarize(root: Json): Summary { + const groups = groupsOf(root).map((g): GroupSummary => { + const name = groupName(g) + const counts = {} as Record + for (const { field } of URL_FIELDS) counts[field] = entriesOf(g, field).length + for (const { field } of RULE_FIELDS) counts[field] = entriesOf(g, field).length + return { + name, + enabled: g.enableBlocking === true, + counts, + networks: mapped(root.networkGroupMap, name), + endpoints: mapped(root.localEndPointGroupMap, name), + } + }) + return { enabled: root.enableBlocking === true, blocking: blocksNow(root), groups } +} + +/* +The block list URLs the app downloads that count towards "Block List Domains": the plain +block lists, the regex ones are patterns and not domains and are left out, the adblock +ones are domains. Each once, in the order they appear. +*/ +export function countableUrls(root: Json): string[] { + const seen = new Set() + for (const g of groupsOf(root)) { + for (const field of ['blockListUrls', 'adblockListUrls'] as const) { + for (const e of entriesOf(g, field)) { + const url = entryText(e).trim() + if (/^https?:\/\//i.test(url)) seen.add(url) + } + } + } + return [...seen] +} + +/* +Changing a group. Every function returns a NEW root: the groups array and the one group +are copied, every other key keeps its value and its place. A field the group did not have +is added at its end; one it had is replaced where it stands. +*/ +export function withGroupField(root: Json, name: string, field: GroupField, values: unknown[]): Json { + const groups = Array.isArray(root.groups) ? root.groups : [] + const at = groups.findIndex((g) => isObject(g) && groupName(g) === name) + if (at < 0) return root + const next = groups.map((g, i) => (i === at ? { ...(g as Json), [field]: values } : g)) + return { ...root, groups: next } +} + +/** A new entry in the form the list already has: an object with a `url` when its entries are objects. */ +export function newEntry(existing: readonly unknown[], text: string): unknown { + return existing.some(isObject) ? { url: text } : text +} + +/** The entry with another text, keeping every other key of an object entry. */ +export function replaceText(entry: unknown, text: string): unknown { + return isObject(entry) ? { ...entry, url: text } : text +} + +export function addEntry(root: Json, name: string, field: GroupField, text: string): Json { + const group = groupsOf(root).find((g) => groupName(g) === name) + if (group == null) return root + const list = entriesOf(group, field) + return withGroupField(root, name, field, [...list, newEntry(list, text)]) +} + +export function removeEntry(root: Json, name: string, field: GroupField, index: number): Json { + const group = groupsOf(root).find((g) => groupName(g) === name) + if (group == null) return root + return withGroupField(root, name, field, entriesOf(group, field).filter((_, i) => i !== index)) +} + +export function editEntry(root: Json, name: string, field: GroupField, index: number, text: string): Json { + const group = groupsOf(root).find((g) => groupName(g) === name) + if (group == null) return root + return withGroupField(root, name, field, entriesOf(group, field).map((e, i) => (i === index ? replaceText(e, text) : e))) +} + +export const isUrl = (text: string): boolean => /^(https?|file):\/\/\S+$/i.test(text) diff --git a/src/screens/blocking/app-list-count.test.ts b/src/screens/blocking/app-list-count.test.ts new file mode 100644 index 0000000..6f52727 --- /dev/null +++ b/src/screens/blocking/app-list-count.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_MAX_BYTES, countInBrowser, forgetListCounts } from './app-list-count' + +beforeEach(() => forgetListCounts()) + +describe('countInBrowser', () => { + it('counts a list and remembers it for the session', async () => { + const fetcher = vi.fn(async () => new Response('0.0.0.0 a.example\n0.0.0.0 b.example\n')) + expect(await countInBrowser('https://l.example/a', fetcher as never)).toEqual({ url: 'https://l.example/a', count: 2 }) + await countInBrowser('https://l.example/a', fetcher as never) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('reports a list the browser may not read as not counted, never as zero', async () => { + const blocked = vi.fn(async () => { + throw new TypeError('Failed to fetch') + }) + const r = await countInBrowser('https://l.example/cors', blocked as never) + expect(r.count).toBeNull() + expect(r.error).toMatch(/CORS/) + }) + + it('reports an error status and an oversize list', async () => { + expect((await countInBrowser('https://l.example/404', (async () => new Response('', { status: 404 })) as never)).error).toBe('status 404') + const big = (async () => new Response('a.example', { headers: { 'content-length': String(BROWSER_MAX_BYTES + 1) } })) as never + expect((await countInBrowser('https://l.example/big', big)).error).toBe('too large') + }) +}) diff --git a/src/screens/blocking/app-list-count.ts b/src/screens/blocking/app-list-count.ts new file mode 100644 index 0000000..3742ff4 --- /dev/null +++ b/src/screens/blocking/app-list-count.ts @@ -0,0 +1,150 @@ +import { useContext, useEffect, useState } from 'react' +import { getAppListCounts, type ListCount } from '../../api/app-lists' +import { countDomains } from '../../lib/list-count' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { APP_NAME, countableUrls } from './advanced-blocking' +import type { AppState } from './advanced-blocking-state' + +/* +The domains in the Advanced Blocking app's block lists, for the "Block List Domains" +figure. OURS. The app has no API for this count, so the lists are downloaded and counted: + +- behind a hub, by the hub (`hub/blocking/app-lists/count`), which caches them for hours; +- without one, by this page with \`fetch\`. A list whose server does not allow it (CORS), is + too big or does not answer is reported as not counted, never as zero. The answers are kept + in memory for the session. + +It never holds the page up: the figure shows the built-in count at once and this arrives +later. Lists shared by several groups are counted once; a domain in two lists counts in both. +*/ + +export type AppCount = + | { phase: 'loading' } + | { phase: 'failed' } + | { phase: 'done'; total: number; lists: ListCount[]; uncounted: number } + +/** What a download may take, so a huge or slow file cannot hold the page. */ +export const BROWSER_TIMEOUT_MS = 20_000 +export const BROWSER_MAX_BYTES = 64 * 1024 * 1024 + +const session = new Map() + +/** For the tests. */ +export function forgetListCounts(): void { + session.clear() +} + +export async function countInBrowser(url: string, fetcher: typeof fetch = fetch): Promise { + const hit = session.get(url) + if (hit != null) return hit + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), BROWSER_TIMEOUT_MS) + let result: ListCount + try { + const res = await fetcher(url, { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' }) + if (!res.ok) throw new Error(`status ${res.status}`) + const declared = Number(res.headers.get('content-length') ?? 0) + if (declared > BROWSER_MAX_BYTES) throw new Error('too large') + const text = await res.text() + if (text.length > BROWSER_MAX_BYTES) throw new Error('too large') + result = { url, count: countDomains(text) } + } catch (e) { + const aborted = e instanceof Error && e.name === 'AbortError' + result = { + url, + count: null, + error: aborted ? 'timed out' : e instanceof TypeError ? 'blocked by the list server (CORS) or unreachable' : e instanceof Error ? e.message : 'failed', + } + } finally { + clearTimeout(timer) + } + session.set(url, result) + return result +} + +export function useAppListCount(token: string | null, node: string, state: AppState): AppCount | null { + const hub = useContext(HubMode) + const urls = state.kind === 'ready' ? countableUrls(state.root) : [] + const name = state.kind === 'ready' ? state.app.name : APP_NAME + const key = JSON.stringify(urls) + const [count, setCount] = useState(null) + + useEffect(() => { + const wanted = JSON.parse(key) as string[] + if (wanted.length === 0) { + setCount(null) + return + } + let live = true + setCount({ phase: 'loading' }) + const finish = (lists: ListCount[]) => { + if (!live) return + // Only the lists asked for, so a stale answer from the hub cannot add a list the config dropped. + const asked = lists.filter((l) => wanted.includes(l.url)) + const total = asked.reduce((n, l) => n + (l.count ?? 0), 0) + setCount({ phase: 'done', total, lists: asked, uncounted: wanted.length - asked.filter((l) => l.count != null).length }) + } + if (hub) { + void getAppListCounts(token, name, node).then((r) => { + if (!live) return + if (r.kind !== 'ok') setCount({ phase: 'failed' }) + else finish(r.data.response.lists ?? []) + }) + } else { + void Promise.all(wanted.map((u) => countInBrowser(u))).then(finish) + } + return () => { + live = false + } + }, [key, hub, token, node, name]) + + return state.kind === 'ready' ? count : null +} + +/* +"All servers" behind a hub: each server's app lists are counted by the hub and the figures are +added up. A list shared by two servers is counted once per server, as the built-in figure is added +across servers. A server without the app (or with no block lists) answers an empty list and adds +nothing; when no server has any list there is nothing to say (`null`). `servers` empty means this +is not the all-servers view and nothing is read. +*/ +export function useAllServersListCount(token: string | null, servers: readonly string[]): AppCount | null { + const key = JSON.stringify(servers) + const [count, setCount] = useState(null) + + useEffect(() => { + const names = JSON.parse(key) as string[] + if (names.length === 0) { + setCount(null) + return + } + let live = true + setCount({ phase: 'loading' }) + void Promise.all(names.map((n) => getAppListCounts(token, APP_NAME, n))).then((answers) => { + if (!live) return + const lists: ListCount[] = [] + let failed = 0 + for (const r of answers) { + if (r.kind !== 'ok') failed++ + else lists.push(...(r.data.response.lists ?? [])) + } + if (failed === answers.length) setCount({ phase: 'failed' }) + else if (lists.length === 0 && failed === 0) setCount(null) + else { + const counted = lists.filter((l) => l.count != null) + setCount({ + phase: 'done', + total: counted.reduce((n, l) => n + (l.count ?? 0), 0), + lists, + /* A server that did not answer may have lists; the figure is "at least" then. */ + uncounted: lists.length - counted.length + failed, + }) + } + }) + return () => { + live = false + } + }, [key, token]) + + return count +} diff --git a/src/screens/blocking/blocking-state.ts b/src/screens/blocking/blocking-state.ts index ba306a7..02b5955 100644 --- a/src/screens/blocking/blocking-state.ts +++ b/src/screens/blocking/blocking-state.ts @@ -3,6 +3,8 @@ import { getSettings } from '../../api/settings' import { minuteStamp } from '../../lib/dates' import { onSettingsChanged } from '../../lib/settings-signal' import { missing, switchNeed, type Permissions } from './permissions' +import { blocksNow } from './advanced-blocking' +import { onAppConfigChanged, readAppState, useAdvancedBlocking, type AppState } from './advanced-blocking-state' /* Whether blocking is on, read where the Overview's status block reads it @@ -20,6 +22,8 @@ When it reads: - when a pause should have ended. The server's own timer is armed a hair BEFORE it computes `till` (BlockListZoneManager.cs:993-995), so reading a second after `till` finds blocking back on; the second also absorbs a small clock skew; +- when the Advanced Blocking app's config is written from this console, since the answer + below depends on it; - when the browser tab is shown again after being hidden: the state may have been changed from elsewhere meanwhile, and a hidden tab is not kept current. Reads are numbered, so an older answer arriving last cannot overwrite a newer one. @@ -32,7 +36,14 @@ export const AFTER_PAUSE_MS = 1000 finds the pause still on and waits again. */ export const MAX_TIMEOUT_MS = 2_147_483_647 -export type BlockingState = { kind: 'on' } | { kind: 'paused'; till: string } | { kind: 'off' } +/* +`app` is OURS: the built-in blocking is off (or paused) but the Advanced Blocking app is +installed, switched on and has a group that blocks, so queries ARE being blocked. `till` +is the end of the built-in pause when that is what the built-in blocking is doing. The app +is only asked about when the built-in blocking is not on, and an app that is missing, +unreadable or not blocking leaves the state as the settings said it. +*/ +export type BlockingState = { kind: 'on' } | { kind: 'paused'; till: string } | { kind: 'off' } | { kind: 'app'; till?: string } export function stateOf(enable: boolean, till: string | null | undefined, now = Date.now()): BlockingState { if (enable) return { kind: 'on' } @@ -66,7 +77,7 @@ export function useBlockingState( node = '', ): { state: BlockingRead; reload: () => void; retry: () => void } { const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView' - const [state, setState] = useState(null) + const [base, setState] = useState(null) const asked = useRef(0) const reload = useCallback(() => { @@ -95,7 +106,7 @@ export function useBlockingState( }, [reload]) /* Without the permission nothing is known, whatever an earlier read said. */ - const known = viewNeed != null ? null : state + const known = viewNeed != null ? null : base const till = known != null && known !== 'failed' && known.kind === 'paused' ? known.till : null useEffect(() => { if (till == null) return @@ -109,7 +120,13 @@ export function useBlockingState( reload() }, [reload]) - return { state: known, reload, retry } + /* The app is asked only when the built-in blocking is off or paused. */ + const builtInOff = known != null && known !== 'failed' && known.kind !== 'on' && known.kind !== 'app' + const app = useAdvancedBlocking(token, permissions, node, builtInOff) + const viaApp = builtInOff && app.state.kind === 'ready' && blocksNow(app.state.root) + const state: BlockingRead = viaApp ? { kind: 'app', ...(till != null ? { till } : {}) } : known + + return { state, reload, retry } } /* @@ -129,7 +146,7 @@ export interface BlockingMark { } export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date(), hub = false): BlockingMark | null { - if (state == null || state === 'failed' || state.kind === 'on') return null + if (state == null || state === 'failed' || state.kind === 'on' || state.kind === 'app') return null if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' } const canLift = switchNeed(permissions, hub) == null return { @@ -140,7 +157,166 @@ export function blockingMark(state: BlockingRead, permissions: Permissions, now } /** The mark for the sidebar's Blocking entry; `visible` false when there is no entry. */ -export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false): BlockingMark | null { - const { state } = useBlockingState(token, permissions, visible) +export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false, node = ''): BlockingMark | null { + const { state } = useBlockingState(token, permissions, visible, node) return blockingMark(state, permissions, new Date(), hub) } + +/* +Behind a hub, all servers together. OURS. "Is blocking on" is a question about each server: its +built-in blocking, or the Advanced Blocking app blocking in its place. With "All servers" chosen, +and for the sidebar's mark (which has no single server to ask), every server is asked for both +(`settings/get` and the app's config, each with its node: the hub refuses a config read without +one) and the answers are put together by `summariseServers` and `hubMark`. + +`method` is how a server blocks: `builtin`, `app`, `both`, or not at all (`nothing`; `paused` when +the built-in blocking is paused and the app does not make up for it). A server whose settings could +not be read is `unknown` and is left out of both summaries rather than guessed at. +*/ +export type Method = 'builtin' | 'app' | 'both' | 'paused' | 'nothing' | 'unknown' + +export interface ServerBlocking { + server: string + method: Method + /** The app is installed here (blocking or not). */ + appInstalled: boolean + appBlocking: boolean +} + +export async function readServerBlocking( + token: string | null, + server: string, + canSettings: boolean, + canApps: boolean, +): Promise { + const [settings, app] = await Promise.all([ + canSettings ? getSettings(token, server) : Promise.resolve(null), + canApps ? readAppState(token, server) : Promise.resolve({ kind: 'none' }), + ]) + const appInstalled = app.kind === 'ready' + const appBlocking = app.kind === 'ready' && blocksNow(app.root) + const built = settings == null ? null : stateOf(settings.enableBlocking, settings.temporaryDisableBlockingTill) + let method: Method + if (built == null) method = appBlocking ? 'app' : 'unknown' + else if (built.kind === 'on') method = appBlocking ? 'both' : 'builtin' + else if (appBlocking) method = 'app' + else method = built.kind === 'paused' ? 'paused' : 'nothing' + return { server, method, appInstalled, appBlocking } +} + +export function useAllServersBlocking( + token: string | null, + permissions: Permissions, + servers: readonly string[], + enabled: boolean, +): ServerBlocking[] | null { + const canSettings = missing(permissions, 'Settings.canView') == null + const canApps = missing(permissions, 'Apps.canView') == null + const key = JSON.stringify(servers) + const [rows, setRows] = useState(null) + const asked = useRef(0) + const active = enabled && canSettings && servers.length > 0 + + const reload = useCallback(() => { + if (!active) return + const mine = ++asked.current + void Promise.all((JSON.parse(key) as string[]).map((s) => readServerBlocking(token, s, canSettings, canApps))).then((r) => { + if (mine === asked.current) setRows(r) + }) + }, [active, key, token, canSettings, canApps]) + + useEffect(() => { + if (!active) { + asked.current++ + return + } + reload() + }, [active, reload]) + useEffect(() => (active ? onSettingsChanged(reload) : undefined), [active, reload]) + useEffect(() => (active ? onAppConfigChanged(reload) : undefined), [active, reload]) + useEffect(() => { + if (!active) return + const shown = () => { + if (document.visibilityState === 'visible') reload() + } + document.addEventListener('visibilitychange', shown) + return () => document.removeEventListener('visibilitychange', shown) + }, [active, reload]) + + return active ? rows : null +} + +const names = (list: readonly string[]) => list.join(', ') + +/* "Built-in on a, b; Advanced Blocking on c" for the servers that block. */ +function methodsText(rows: readonly ServerBlocking[]): string { + const on = (m: Method) => rows.filter((r) => r.method === m).map((r) => r.server) + const parts: string[] = [] + if (on('builtin').length > 0) parts.push(`Built-in on ${names(on('builtin'))}`) + if (on('app').length > 0) parts.push(`Advanced Blocking on ${names(on('app'))}`) + if (on('both').length > 0) parts.push(`Built-in and Advanced Blocking on ${names(on('both'))}`) + return `${parts.join('; ')}.` +} + +export interface ServersSummary { + title: string + sub: string + /** Every server blocks, one way or another. */ + active: boolean +} + +/* +What the status block says for all servers, or `null` when the single-server wording (the first +server's settings) is already right: every server blocks with the built-in blocking, none blocks at +all, or something could not be read. Otherwise: every server blocks and some by the app, or some +server blocks by nothing and the block names it. +*/ +export function summariseServers(rows: readonly ServerBlocking[] | null): ServersSummary | null { + if (rows == null || rows.length === 0 || rows.some((r) => r.method === 'unknown')) return null + const gaps = rows.filter((r) => r.method === 'nothing' || r.method === 'paused') + const blocking = rows.filter((r) => !gaps.includes(r)) + if (gaps.length === rows.length) return null + if (gaps.length > 0) { + return { + title: `Blocking is off on ${names(gaps.map((r) => r.server))}`, + sub: methodsText(blocking), + active: false, + } + } + if (rows.every((r) => r.method === 'builtin')) return null + if (rows.every((r) => r.method === 'app')) { + return { title: 'Blocking by Advanced Blocking', sub: 'The built-in blocking is off on every server.', active: true } + } + return { title: 'Blocking is enabled', sub: methodsText(rows), active: true } +} + +/* The sidebar's mark behind a hub: only when some server blocks by nothing, and it says which. */ +export function hubMark(rows: readonly ServerBlocking[] | null, permissions: Permissions): BlockingMark | null { + if (rows == null) return null + const off = rows.filter((r) => r.method === 'nothing').map((r) => r.server) + if (off.length > 0) return { text: 'Off', label: `Blocking, disabled on ${names(off)}`, tone: 'neutral' } + const paused = rows.filter((r) => r.method === 'paused').map((r) => r.server) + if (paused.length === 0) return null + return { + text: 'Paused', + label: `Blocking, paused on ${names(paused)}`, + tone: switchNeed(permissions, true) == null ? 'acc' : 'neutral', + } +} + +export function useHubBlockingMark( + token: string | null, + permissions: Permissions, + visible: boolean, + servers: readonly string[], +): BlockingMark | null { + return hubMark(useAllServersBlocking(token, permissions, servers, visible), permissions) +} + +/* The line under the status for all servers: where the app is installed. `null` when nowhere. */ +export function appServersLine(rows: readonly ServerBlocking[] | null): string | null { + const have = (rows ?? []).filter((r) => r.appInstalled) + if (have.length === 0) return null + const idle = have.filter((r) => !r.appBlocking).map((r) => r.server) + return `Advanced Blocking: on ${names(have.map((r) => r.server))}${idle.length > 0 ? ` (not blocking on ${names(idle)})` : ''}. Choose a server for details.` +} diff --git a/src/screens/dashboard/Dashboard.module.css b/src/screens/dashboard/Dashboard.module.css index d00967d..d8bb86f 100644 --- a/src/screens/dashboard/Dashboard.module.css +++ b/src/screens/dashboard/Dashboard.module.css @@ -88,6 +88,8 @@ parent's size. /* The share of the total, beside its figure. */ .p { font: 400 var(--t-2)/1.3 var(--font-mono); color: var(--mute); white-space: nowrap; } .ctx { min-width:0; white-space: nowrap; } +/* A context line that is a sentence (Blocking's split of Block List Domains): its own row, wrapping. */ +.ctxWrap { flex: 1 1 100%; white-space: normal; } /* The change: muted like the share, its size in ink, and never red or green (Figures.tsx). */ .change { font: 400 var(--t-2)/1.3 var(--font-mono); font-variant-numeric: tabular-nums; color: var(--mute); } diff --git a/src/screens/dashboard/Figures.tsx b/src/screens/dashboard/Figures.tsx index 9265d8d..30598b1 100644 --- a/src/screens/dashboard/Figures.tsx +++ b/src/screens/dashboard/Figures.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import type { Stats } from '../../api/dashboard' import { num2, percentage, type Metric } from './metrics' import { formatChange, type Changes } from './compare' @@ -68,6 +69,57 @@ const GLYPH: Record = { totalCached: 'cache', totalBlocked: 'blocked', totalDropped: 'power', } +/* +The structure of a KPI cell, written once: the icon tile in the figure's colour, its +name, its figure and the context line beside it. `StatCard` fills it for the +Dashboard's figures; the Blocking Overview fills it for the two figures that are not a +`Metric` (Block List Domains, Your Rules). +*/ +export function KpiTile({ + label, + icon, + colour, + figure, + noData = false, + wrap = false, + children, + onOpen, +}: { + label: string + icon: IconName + /** A colour token's value, e.g. `var(--ch-block)`. */ + colour: string + figure: string + /** The figure is a dash, not a value. */ + noData?: boolean + /** The context line is a sentence: it takes its own row under the figure and may wrap. */ + wrap?: boolean + /** The context line: a share, a change, a sentence. */ + children?: ReactNode + onOpen?: () => void +}) { + return ( +
+ +
+
{label}
+ {/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a + true figure, and here it would be a lie. */} +
+
+
{children}
+
+
+ {/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over + the whole card, so the card's own reading order stays as it is; its name + is the card's label and its title upstream's menu text. */} + {onOpen && ( +
+ ) +} + export function StatCard({ m, s, @@ -90,29 +142,19 @@ export function StatCard({ const share = m.pct && s ? percentage(s[m.k], total) : null const ratio = s ? changes?.[m.k] : undefined return ( -
- -
-
{m.label}
- {/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a - true figure, and here it would be a lie. */} -
-
- {/* The hint beside the figure: its share of the total and its change. */} -
- {share != null && {share}} - {share != null && ratio != null && ' '} - - {share == null && ratio == null && ' '} -
-
-
- {/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over - the whole card, so the card's own reading order stays as it is; its name - is the card's label and its title upstream's menu text. */} - {onOpen && ( -
+ + {/* The hint beside the figure: its share of the total and its change. */} + {share != null && {share}} + {share != null && ratio != null && ' '} + + {share == null && ratio == null && ' '} + ) }