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:
commit
f3531c64f9
680 files changed
+139836
No files matched your search
@@ -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`.
|
||||
@@ -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()))
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user