Move the console onto ReUI PRO

Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# Update check
How the console learns that a newer release exists. The DNS server serves the console with
`default-src 'self'` and no `connect-src`, so the browser cannot ask GitHub. It can ask its own
server to resolve a name with DNS Client, and DNS Client takes a DNS-over-HTTPS URL as the server.
This folder is that DNS-over-HTTPS server, a Cloudflare Pages Function at
`https://technitium-console.pages.dev/dns-query`. It knows one name,
`latest.technitium-console.pages.dev`, and answers its TXT with:
```
v=1 latest=1.4.0 server=15.5
```
`latest` is the latest release on GitHub, the one `install.sh` installs. `server` is the oldest
Technitium DNS Server it is made for: `technitiumServerMinimum` in that tag's `package.json`. A
release is announced only once the Docker image `latest` is that release, so that pulling gets it. The format is frozen: consoles already
installed read only this one, and ignore keys they do not know.
It reads GitHub at most once an hour and keeps the last good answer for up to a month, in case
GitHub is down. That copy lives in Cloudflare's cache, one per Cloudflare location, and can be
evicted sooner. It needs no secrets.
## Deploying
A Cloudflare Pages project connected to this repository:
- Production branch: `main`
- Root directory: `update-check`
- Build command: none
- Build output directory: `public`
## Running it locally
```sh
node --experimental-strip-types dev/update-check-serve.mjs
```
It listens on port 8853. To have it answer a fixed record instead of reading GitHub, set
`UPDATE_CHECK_FAKE='v=1 latest=9.9.9 server=15.5'`. The harness's DNS server reaches it at the
Docker network's gateway, as `http://<gateway>:8853/dns-query`.
+30
View File
@@ -0,0 +1,30 @@
/*
Cloudflare Pages serves this file at /dns-query. Everything it does is in
`src/handler.ts`; this only wires it to the live `fetch` and to the edge cache,
which works on *.pages.dev.
*/
import { handle, lookup, type Store } from '../src/handler.ts'
function edgeCache(): Store {
const cache = (caches as unknown as { default: Cache }).default
const key = (name: string) => `https://technitium-console.pages.dev/__cache/${name}`
return {
async get(name) {
const hit = await cache.match(key(name))
if (!hit) return null
try {
return await hit.json()
} catch {
return null
}
},
async put(name, value, ttlSeconds) {
const body = JSON.stringify(value)
await cache.put(key(name), new Response(body, { headers: { 'cache-control': `max-age=${ttlSeconds}` } }))
},
}
}
export const onRequest = (context: { request: Request }): Promise<Response> =>
handle(context.request, () => lookup(fetch, edgeCache()))
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Technitium Console update check</title>
</head>
<body>
<p>
This is the update check of <a href="https://github.com/byGarcia/technitium-console">technitium-console</a>.
At <code>/dns-query</code> it answers DNS-over-HTTPS for one name,
<code>latest.technitium-console.pages.dev</code>, with a TXT record that says which release is the latest.
</p>
<p>
Your DNS server asks it at most once a day per browser, so that the console can tell you when a newer one is out.
The code is in <a href="https://github.com/byGarcia/technitium-console/tree/main/update-check">update-check/</a>.
</p>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { buildResponse, parseQuery, TYPE_TXT } from './dns-message.ts'
import { encodeQuery } from './test-query.ts'
const NAME = 'latest.technitium-console.pages.dev'
describe('parseQuery', () => {
it('parses the question Technitium sends', () => {
const q = parseQuery(encodeQuery('Latest.Technitium-Console.pages.dev', 16, 0, true, 1))
expect(q).toMatchObject({ id: 0, rd: true, name: NAME, type: 16 })
expect(q?.question.length).toBe(1 + 6 + 1 + 18 + 1 + 5 + 1 + 3 + 1 + 4)
})
it('refuses what it cannot read', () => {
expect(parseQuery(new Uint8Array(5))).toBeNull()
const two = encodeQuery(NAME, 16)
new DataView(two.buffer).setUint16(4, 2)
expect(parseQuery(two)).toBeNull()
const long = encodeQuery(NAME, 16)
long[12] = 60
expect(parseQuery(long)).toBeNull()
// RFC 1035: a name is at most 255 bytes on the wire.
expect(parseQuery(encodeQuery(Array(50).fill('abcdefgh').join('.'), 16))).toBeNull()
const pointer = encodeQuery(NAME, 16)
pointer[12] = 0xc0
expect(parseQuery(pointer)).toBeNull()
})
})
describe('buildResponse', () => {
const query = parseQuery(encodeQuery(NAME, TYPE_TXT, 0x1234, true, 1))!
it('answers with one TXT', () => {
const text = 'v=1 latest=1.3.2 server=15.5'
const r = buildResponse(query, 0, { text, ttl: 3600 })
const view = new DataView(r.buffer)
expect(view.getUint16(0)).toBe(0x1234)
expect(view.getUint16(2)).toBe(0x8580)
expect(view.getUint16(4)).toBe(1)
expect(view.getUint16(6)).toBe(1)
expect(view.getUint16(8)).toBe(0)
expect(view.getUint16(10)).toBe(0)
expect([...r.slice(12, 12 + query.question.length)]).toEqual([...query.question])
const at = 12 + query.question.length
expect([...r.slice(at, at + 10)]).toEqual([0xc0, 0x0c, 0x00, 0x10, 0x00, 0x01, 0x00, 0x00, 0x0e, 0x10])
expect(view.getUint16(at + 10)).toBe(29)
expect(r[at + 12]).toBe(28)
expect(new TextDecoder().decode(r.slice(at + 13))).toBe(text)
})
it('answers NXDOMAIN with no records', () => {
const r = buildResponse(query, 3)
const view = new DataView(r.buffer)
expect(view.getUint16(2) & 0x000f).toBe(3)
expect(view.getUint16(6)).toBe(0)
expect(r.length).toBe(12 + query.question.length)
})
it('splits long text', () => {
const r = buildResponse(query, 0, { text: 'x'.repeat(300), ttl: 60 })
const at = 12 + query.question.length + 12
expect(r[at]).toBe(255)
expect(r[at + 1 + 255]).toBe(45)
expect(r.length).toBe(at + 1 + 255 + 1 + 45)
})
})
+97
View File
@@ -0,0 +1,97 @@
/*
Just enough of the DNS wire format (RFC 1035) to answer one question.
The DNS server asks this endpoint over DNS-over-HTTPS (RFC 8484), so what comes
in is a binary DNS message and what goes out must be one too. Technitium checks
that the answer carries the query's ID and question (`ValidateResponse` in
TechnitiumLibrary's `HttpsClientConnection.cs`), and nothing else: it accepts an
answer without an OPT record even when the query had one.
Web APIs only (`Uint8Array`, `DataView`): `Buffer` does not exist in a Cloudflare
Function.
*/
export const TYPE_TXT = 16
export interface DnsQuery {
id: number
rd: boolean
/** Lower-cased, without the trailing dot. */
name: string
type: number
/** The question section as it came: QNAME, QTYPE and QCLASS. */
question: Uint8Array<ArrayBuffer>
}
export function parseQuery(message: Uint8Array): DnsQuery | null {
if (message.length < 12) return null
const view = new DataView(message.buffer, message.byteOffset, message.byteLength)
if (view.getUint16(4) !== 1) return null
const labels: string[] = []
let at = 12
for (;;) {
if (at >= message.length) return null
const length = message[at]
if (length === 0) break
// A query has no earlier name to point to, so compression is malformed here.
if (length > 63) return null
if (at + 1 + length > message.length) return null
labels.push(String.fromCharCode(...message.subarray(at + 1, at + 1 + length)))
at += 1 + length
}
at += 1
// RFC 1035: a name is at most 255 bytes on the wire, its root included.
if (at - 12 > 255) return null
if (at + 4 > message.length) return null
return {
id: view.getUint16(0),
rd: (view.getUint16(2) & 0x0100) !== 0,
name: labels.join('.').toLowerCase(),
type: view.getUint16(at),
question: message.slice(12, at + 4),
}
}
/* rcode: 0 NOERROR, 1 FORMERR, 2 SERVFAIL, 3 NXDOMAIN. */
export function buildResponse(
query: DnsQuery,
rcode: 0 | 1 | 2 | 3,
txt?: { text: string; ttl: number },
): Uint8Array<ArrayBuffer> {
const strings: Uint8Array[] = []
if (txt) {
const bytes = new TextEncoder().encode(txt.text)
for (let i = 0; i < bytes.length; i += 255) strings.push(bytes.subarray(i, i + 255))
if (strings.length === 0) strings.push(new Uint8Array(0))
}
const rdlength = strings.reduce((n, s) => n + 1 + s.length, 0)
const answer = txt ? 12 + rdlength : 0
const out = new Uint8Array(12 + query.question.length + answer)
const view = new DataView(out.buffer)
view.setUint16(0, query.id)
// QR, AA and RA set; RD copied from the query.
view.setUint16(2, 0x8000 | 0x0400 | (query.rd ? 0x0100 : 0) | 0x0080 | rcode)
view.setUint16(4, 1)
view.setUint16(6, txt ? 1 : 0)
out.set(query.question, 12)
if (txt) {
let at = 12 + query.question.length
// The answer's name points back to the question's (offset 12).
view.setUint16(at, 0xc00c)
view.setUint16(at + 2, TYPE_TXT)
view.setUint16(at + 4, 1)
view.setUint32(at + 6, txt.ttl)
view.setUint16(at + 10, rdlength)
at += 12
for (const s of strings) {
out[at++] = s.length
out.set(s, at)
at += s.length
}
}
return out
}
+195
View File
@@ -0,0 +1,195 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { handle, lookup, RECORD_NAME, type Store } from './handler.ts'
import { parseQuery, TYPE_TXT } from './dns-message.ts'
import type { Release } from './source.ts'
import { encodeQuery } from './test-query.ts'
const RECORD: Release = { latest: '1.4.0', server: '15.5' }
const URL = 'https://technitium-console.pages.dev/dns-query'
function post(name: string, type: number): Request {
return new Request(URL, {
method: 'POST',
headers: { 'content-type': 'application/dns-message' },
body: encodeQuery(name, type, 7, true, 1),
})
}
async function answer(response: Response) {
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/dns-message')
const bytes = new Uint8Array(await response.arrayBuffer())
const view = new DataView(bytes.buffer)
const query = parseQuery(bytes)!
const at = 12 + query.question.length
return {
id: view.getUint16(0),
rcode: view.getUint16(2) & 0x000f,
count: view.getUint16(6),
text: view.getUint16(6) ? new TextDecoder().decode(bytes.slice(at + 13)) : null,
}
}
describe('handle', () => {
it('answers the record over POST', async () => {
const a = await answer(await handle(post(RECORD_NAME, TYPE_TXT), async () => RECORD))
expect(a).toEqual({ id: 7, rcode: 0, count: 1, text: 'v=1 latest=1.4.0 server=15.5' })
})
it('answers the record over GET', async () => {
const bytes = encodeQuery(RECORD_NAME, TYPE_TXT, 9)
const dns = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const a = await answer(await handle(new Request(`${URL}?dns=${dns}`), async () => RECORD))
expect(a).toEqual({ id: 9, rcode: 0, count: 1, text: 'v=1 latest=1.4.0 server=15.5' })
})
it('answers NXDOMAIN for another name', async () => {
const a = await answer(await handle(post('example.com', TYPE_TXT), async () => RECORD))
expect(a.rcode).toBe(3)
expect(a.count).toBe(0)
})
it('answers empty for another type', async () => {
const a = await answer(await handle(post(RECORD_NAME, 1), async () => RECORD))
expect(a).toMatchObject({ rcode: 0, count: 0 })
})
it('answers empty when there is nothing to announce', async () => {
const a = await answer(await handle(post(RECORD_NAME, TYPE_TXT), async () => null))
expect(a).toMatchObject({ rcode: 0, count: 0 })
})
it('answers SERVFAIL when the source fails', async () => {
const failing = async () => {
throw new Error('down')
}
const a = await answer(await handle(post(RECORD_NAME, TYPE_TXT), failing))
expect(a).toMatchObject({ rcode: 2, count: 0 })
})
it('refuses other methods', async () => {
expect((await handle(new Request(URL, { method: 'PUT', body: 'x' }), async () => RECORD)).status).toBe(405)
})
/* A DNS query is a few dozen bytes. A larger body is not one, and is not
echoed back. */
it('refuses an oversized body', async () => {
const big = new Uint8Array(70000)
big.set(encodeQuery(RECORD_NAME, TYPE_TXT))
const r = await handle(new Request(URL, { method: 'POST', body: big }), async () => RECORD)
expect(r.status).toBe(413)
})
it('refuses a declared oversized body without reading it', async () => {
const request = new Request(URL, { method: 'POST', body: new Uint8Array(10), headers: { 'content-length': '70000' } })
let read = false
const spied = new Proxy(request, { get: (t, k) => (k === 'arrayBuffer' ? () => ((read = true), t.arrayBuffer()) : Reflect.get(t, k)) })
expect((await handle(spied as Request, async () => RECORD)).status).toBe(413)
expect(read).toBe(false)
})
it('refuses an oversized GET query', async () => {
const r = await handle(new Request(`${URL}?dns=${'A'.repeat(8000)}`), async () => RECORD)
expect(r.status).toBe(413)
})
it('refuses a body it cannot read', async () => {
const r = await handle(new Request(URL, { method: 'POST', body: new Uint8Array(3) }), async () => RECORD)
expect(r.status).toBe(400)
})
})
function memory(initial: Record<string, unknown> = {}): Store & { puts: [string, number][] } {
const data = new Map(Object.entries(initial))
const puts: [string, number][] = []
return {
puts,
async get(key) {
return data.get(key) ?? null
},
async put(key, value, ttl) {
puts.push([key, ttl])
data.set(key, value)
},
}
}
/* A GitHub and GHCR that answer `tag`, or 503 when it is null. Counts the releases page. */
function source(tag: string | null, state = { reads: 0 }, pkg: unknown = { technitiumServerMinimum: '15.5' }) {
const fn = (async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith('/releases/latest')) {
state.reads++
if (!tag) return new Response('', { status: 503 })
return new Response(null, { status: 302, headers: { location: `https://github.com/x/releases/tag/${tag}` } })
}
if (url.endsWith('/package.json')) return Response.json(pkg)
if (url.startsWith('https://ghcr.io/token')) return Response.json({ token: 't' })
if (url.endsWith('/manifests/latest')) {
return new Response(null, { headers: { 'docker-content-digest': 'sha256:latest' } })
}
if (tag === 'v1.5.0') return new Response(null, { status: 404 })
return new Response(null, { headers: { 'docker-content-digest': 'sha256:latest' } })
}) as typeof fetch
return { fn, state }
}
describe('lookup', () => {
it('reads once an hour', async () => {
const store = memory()
const { fn, state } = source('v1.4.0')
expect(await lookup(fn, store)).toEqual(RECORD)
expect(await lookup(fn, store)).toEqual(RECORD)
expect(state.reads).toBe(1)
expect(store.puts).toEqual([
['fresh', 3600],
['last', 30 * 24 * 3600],
])
})
/* Anyone can send queries. Without a cached "nothing", each one would cost
GitHub two requests while the latest release has nothing to announce. */
it('reads once per ten minutes when there is nothing to announce', async () => {
const store = memory()
const { fn, state } = source('v1.3.1', { reads: 0 }, {})
expect(await lookup(fn, store)).toBeNull()
expect(await lookup(fn, store)).toBeNull()
expect(state.reads).toBe(1)
expect(store.puts).toEqual([['fresh', 600]])
})
it('backs off for a minute after a failure with nothing kept', async () => {
const store = memory()
const { fn, state } = source(null)
await expect(lookup(fn, store)).rejects.toThrow()
expect(await lookup(fn, store)).toBeNull()
expect(state.reads).toBe(1)
expect(store.puts).toEqual([['fresh', 60]])
})
it('ignores a cached value that is not a release', async () => {
const { fn, state } = source('v1.4.0')
const store = memory({ fresh: { release: { latest: '1.4.0<b>', server: '15.5' } }, last: 'x' })
expect(await lookup(fn, store)).toEqual(RECORD)
expect(state.reads).toBe(1)
})
it('serves the last good record when the source fails', async () => {
const { fn } = source(null)
expect(await lookup(fn, memory({ last: RECORD }))).toEqual(RECORD)
})
it('backs off for a minute while serving the last good record', async () => {
const store = memory({ last: RECORD })
const { fn, state } = source(null)
await lookup(fn, store)
expect(await lookup(fn, store)).toEqual(RECORD)
expect(state.reads).toBe(1)
})
it('passes the last record on while the image is missing', async () => {
const { fn } = source('v1.5.0')
expect(await lookup(fn, memory({ last: RECORD }))).toEqual(RECORD)
})
})
+122
View File
@@ -0,0 +1,122 @@
/*
The endpoint: a DNS-over-HTTPS server (RFC 8484) that knows one name.
The console cannot ask GitHub itself: the DNS server serves it with
`default-src 'self'` and no `connect-src`. What it can do is ask its own server
to resolve a name with DNS Client (`dnsClient/resolve`), and DNS Client accepts
a DoH URL as the server. So this answers `latest.technitium-console.pages.dev`
TXT with the latest release, and everything else with NXDOMAIN or nothing.
*/
import { buildResponse, parseQuery, TYPE_TXT } from './dns-message.ts'
import { formatRecord, readRelease, type Release } from './source.ts'
export const RECORD_NAME = 'latest.technitium-console.pages.dev'
const RECORD_TTL = 3600
const FRESH_TTL = 3600
const NOTHING_TTL = 600
const FAILURE_TTL = 60
const LAST_TTL = 30 * 24 * 3600
const MAX_QUERY = 4096
const VERSION = /^\d+\.\d+(\.\d+)?$/
/* What comes back from `get` is checked before use: the cache is storage this
code does not own end to end, so it is read as untrusted. */
export interface Store {
get(key: string): Promise<unknown>
put(key: string, value: unknown, ttlSeconds: number): Promise<void>
}
function asRelease(value: unknown): Release | null {
const r = value as Partial<Release> | null
if (!r || typeof r.latest !== 'string' || typeof r.server !== 'string') return null
if (!/^\d+\.\d+\.\d+$/.test(r.latest) || !VERSION.test(r.server)) return null
return { latest: r.latest, server: r.server }
}
/*
GitHub and GHCR are read at most once an hour. When they fail, the last good
record keeps being served for up to a month, so an outage on their side does not
silence every console; with nothing kept, the failure reaches the caller.
"Nothing to announce" and a failure with nothing kept are cached too, for ten
minutes and one minute. Anyone can send queries, and without that each one
would cost GitHub two to four requests.
*/
export async function lookup(fetchFn: typeof fetch, store: Store): Promise<Release | null> {
const fresh = (await store.get('fresh')) as { release?: unknown } | null
if (fresh && typeof fresh === 'object' && 'release' in fresh) {
if (fresh.release === null) return null
const release = asRelease(fresh.release)
if (release) return release
}
const last = asRelease(await store.get('last'))
let release: Release | null
try {
release = await readRelease(fetchFn, last)
} catch (error) {
await store.put('fresh', { release: last }, FAILURE_TTL)
if (last) return last
throw error
}
if (release) {
await store.put('fresh', { release }, FRESH_TTL)
await store.put('last', release, LAST_TTL)
} else {
await store.put('fresh', { release: null }, NOTHING_TTL)
}
return release
}
function message(bytes: Uint8Array<ArrayBuffer>): Response {
return new Response(bytes, {
headers: { 'content-type': 'application/dns-message', 'cache-control': 'no-store' },
})
}
function tooLarge(): Response {
return new Response('Payload Too Large', { status: 413 })
}
function fromBase64Url(text: string): Uint8Array | null {
try {
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'))
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
} catch {
return null
}
}
export async function handle(request: Request, find: () => Promise<Release | null>): Promise<Response> {
let body: Uint8Array | null = null
if (request.method === 'POST') {
if (Number(request.headers.get('content-length') ?? 0) > MAX_QUERY) return tooLarge()
body = new Uint8Array(await request.arrayBuffer())
// A query for one name is a few dozen bytes. Anything this large is not
// one, and is not echoed back.
if (body.length > MAX_QUERY) return tooLarge()
} else if (request.method === 'GET') {
const dns = new URL(request.url).searchParams.get('dns') ?? ''
if (dns.length > (MAX_QUERY * 4) / 3 + 4) return tooLarge()
body = fromBase64Url(dns)
}
else return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, POST' } })
// Without a readable question there is nothing to echo back: RFC 8484 lets a
// DoH server answer such a request with a plain 400.
const query = body ? parseQuery(body) : null
if (!query) return new Response('Bad Request', { status: 400 })
if (query.name !== RECORD_NAME) return message(buildResponse(query, 3))
if (query.type !== TYPE_TXT) return message(buildResponse(query, 0))
let release: Release | null
try {
release = await find()
} catch {
return message(buildResponse(query, 2))
}
if (!release) return message(buildResponse(query, 0))
return message(buildResponse(query, 0, { text: formatRecord(release), ttl: RECORD_TTL }))
}
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { formatRecord, readRelease, type Release } from './source.ts'
const RELEASES = 'https://github.com/byGarcia/technitium-console/releases/latest'
const TOKEN = 'https://ghcr.io/token?scope=repository:bygarcia/technitium-console:pull'
interface Fake {
tag?: string
pkg?: unknown
pkgStatus?: number
image?: number
/** The digest `latest` points at; by default the same as the release's. */
latestDigest?: string
releases?: number
}
function fakeFetch({
tag = 'v1.4.0',
pkg = { technitiumServerMinimum: '15.5' },
pkgStatus = 200,
image = 200,
latestDigest = 'sha256:new',
releases = 302,
}: Fake) {
const calls: { url: string; init?: RequestInit }[] = []
const fn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
calls.push({ url, init })
if (url === RELEASES) {
if (releases !== 302) return new Response('', { status: releases })
return new Response(null, {
status: 302,
headers: { location: `https://github.com/byGarcia/technitium-console/releases/tag/${tag}` },
})
}
if (url === `https://raw.githubusercontent.com/byGarcia/technitium-console/${tag}/package.json`) {
return pkgStatus === 200 ? Response.json(pkg) : new Response('', { status: pkgStatus })
}
if (url === TOKEN) return Response.json({ token: 'anon' })
if (url.endsWith('/manifests/latest')) {
return new Response(null, { status: 200, headers: { 'docker-content-digest': latestDigest } })
}
if (url.startsWith('https://ghcr.io/v2/bygarcia/technitium-console/manifests/')) {
return new Response(null, { status: image, headers: image === 200 ? { 'docker-content-digest': 'sha256:new' } : {} })
}
return new Response('', { status: 404 })
}) as typeof fetch
return { fn, calls }
}
const previous: Release = { latest: '1.3.2', server: '15.5' }
describe('readRelease', () => {
it('reads the latest release and its server minimum', async () => {
const { fn, calls } = fakeFetch({})
const r = await readRelease(fn, null)
expect(r).toEqual({ latest: '1.4.0', server: '15.5' })
expect(formatRecord(r!)).toBe('v=1 latest=1.4.0 server=15.5')
expect(calls.find((c) => c.url === RELEASES)?.init?.redirect).toBe('manual')
const head = calls.find((c) => c.url.endsWith('/manifests/1.4.0'))
expect(head?.init?.method).toBe('HEAD')
expect(new Headers(head?.init?.headers).get('authorization')).toBe('Bearer anon')
expect(calls.some((c) => c.url.includes('api.github.com'))).toBe(false)
})
it('announces the previous release while the image is missing', async () => {
const { fn } = fakeFetch({ image: 404 })
expect(await readRelease(fn, previous)).toEqual(previous)
})
/* The image job pushes X.Y.Z first and moves latest only after checking the
bytes of every platform; the README's compose file pulls latest. */
it('announces the previous release until latest points at its image', async () => {
const { fn } = fakeFetch({ latestDigest: 'sha256:old' })
expect(await readRelease(fn, previous)).toEqual(previous)
})
it('announces nothing for a tag without the field', async () => {
const { fn } = fakeFetch({ pkg: {} })
expect(await readRelease(fn, previous)).toBeNull()
})
it('refuses a malformed tag or minimum', async () => {
expect(await readRelease(fakeFetch({ tag: 'v1.4' }).fn, null)).toBeNull()
expect(await readRelease(fakeFetch({ pkg: { technitiumServerMinimum: '15.5; rm' } }).fn, null)).toBeNull()
})
it('throws when GitHub fails', async () => {
await expect(readRelease(fakeFetch({ releases: 503 }).fn, null)).rejects.toThrow()
})
/* A rate limit is a failure, not an answer: the caller must keep serving
what it had. raw.githubusercontent.com limits per IP, and Cloudflare's
outgoing addresses are shared. */
it('throws when GitHub rate-limits the releases page', async () => {
await expect(readRelease(fakeFetch({ releases: 429 }).fn, previous)).rejects.toThrow()
})
it('throws when GitHub rate-limits the raw file', async () => {
await expect(readRelease(fakeFetch({ pkgStatus: 429 }).fn, previous)).rejects.toThrow()
})
it('announces nothing when the tag has no package.json', async () => {
expect(await readRelease(fakeFetch({ pkgStatus: 404 }).fn, previous)).toBeNull()
})
})
+67
View File
@@ -0,0 +1,67 @@
/*
What there is to announce: the latest release, the oldest Technitium it is made
for, and whether Docker users can already get it.
None of it uses api.github.com. Its anonymous limit is per IP and Cloudflare's
outgoing addresses are shared, so the release comes from the redirect of the
releases page and the minimum from the raw file at that tag.
`readRelease` returns null only on a definite answer that there is nothing to
announce, and throws on anything else (a rate limit included), so that the
caller can keep serving what it had.
*/
export interface Release {
latest: string
server: string
}
const REPO = 'byGarcia/technitium-console'
const IMAGE = 'bygarcia/technitium-console'
const RELEASE_TAG = /\/releases\/tag\/v(\d+\.\d+\.\d+)$/
const VERSION = /^\d+\.\d+(\.\d+)?$/
/* The record's format is frozen: consoles already installed read only this. */
export function formatRecord(r: Release): string {
return `v=1 latest=${r.latest} server=${r.server}`
}
export async function readRelease(fetchFn: typeof fetch, previous: Release | null): Promise<Release | null> {
const page = await fetchFn(`https://github.com/${REPO}/releases/latest`, { redirect: 'manual' })
if (page.status < 300 || page.status >= 400) throw new Error(`releases/latest answered ${page.status}`)
const tag = RELEASE_TAG.exec(page.headers.get('location') ?? '')
if (!tag) return null
const latest = tag[1]
const pkg = await fetchFn(`https://raw.githubusercontent.com/${REPO}/v${latest}/package.json`)
if (pkg.status === 404) return null
if (!pkg.ok) throw new Error(`package.json answered ${pkg.status}`)
// Releases before the update notice have no such field: nothing to announce.
const server = ((await pkg.json()) as { technitiumServerMinimum?: unknown }).technitiumServerMinimum
if (typeof server !== 'string' || !VERSION.test(server)) return null
// The GitHub release is published before its image (release.yml: `image`
// needs `release`), and the image job pushes X.Y.Z before it moves `latest`,
// which it does only once every platform's bytes are checked
// (.github/actions/init-image). The README's compose file pulls `latest`, so
// until `latest` is this release's image, the previous announcement stands.
const auth = await fetchFn(`https://ghcr.io/token?scope=repository:${IMAGE}:pull`)
if (!auth.ok) throw new Error(`ghcr.io token answered ${auth.status}`)
const { token } = (await auth.json()) as { token: string }
const digest = async (reference: string): Promise<string | null> => {
const manifest = await fetchFn(`https://ghcr.io/v2/${IMAGE}/manifests/${reference}`, {
method: 'HEAD',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json',
},
})
if (manifest.status === 404) return null
if (!manifest.ok) throw new Error(`ghcr.io manifest ${reference} answered ${manifest.status}`)
return manifest.headers.get('docker-content-digest')
}
const release = await digest(latest)
if (!release || release !== (await digest('latest'))) return previous
return { latest, server }
}
+30
View File
@@ -0,0 +1,30 @@
/*
Builds the DNS query a client would POST, for the tests. With `arcount` 1 it
appends an empty OPT record, because Technitium sends one with every query.
*/
export function encodeQuery(name: string, type: number, id = 0, rd = true, arcount = 0): Uint8Array<ArrayBuffer> {
const labels = name.split('.').filter((l) => l.length > 0)
const qname = labels.reduce((n, l) => n + 1 + l.length, 1)
const opt = arcount === 1 ? 11 : 0
const out = new Uint8Array(12 + qname + 4 + opt)
const view = new DataView(out.buffer)
view.setUint16(0, id)
view.setUint16(2, rd ? 0x0100 : 0)
view.setUint16(4, 1)
view.setUint16(10, arcount)
let at = 12
for (const label of labels) {
out[at++] = label.length
for (const ch of label) out[at++] = ch.charCodeAt(0)
}
out[at++] = 0
view.setUint16(at, type)
view.setUint16(at + 2, 1)
at += 4
if (opt) {
out[at] = 0
view.setUint16(at + 1, 41)
view.setUint16(at + 3, 1232)
}
return out
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "../node_modules/.tmp/tsconfig.update-check.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"moduleResolution": "bundler",
"types": [],
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"strict": true
},
"include": ["src", "functions"]
}