diff --git a/app/.server/init/certs.ts b/app/.server/init/certs.ts new file mode 100644 index 00000000..fac2d6c1 --- /dev/null +++ b/app/.server/init/certs.ts @@ -0,0 +1,119 @@ +import fs from "fs"; +import path from "path"; + +// src/pki.ts +import * as x509 from "@peculiar/x509"; +import { DataStore } from "@thorium/utils/.server/db-fs"; + +const HOSTNAME = "thorium.local"; + +// Point the library at the native Web Crypto implementation +x509.cryptoProvider.set(crypto); + +const EC_ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" } as const; + +async function generateCA() { + const keys = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: generateSerial(), + name: "CN=Thorium Nova Local CA, O=Thorium Nova", + notBefore: new Date(), + notAfter: new Date(Date.now() + 30 * 365 * 24 * 60 * 60 * 1000), + signingAlgorithm: EC_ALG, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, undefined, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, + true, + ), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey), + ], + }); + + return { cert, keys }; +} + +function generateSerial(): string { + const bytes = crypto.getRandomValues(new Uint8Array(4)); + bytes[0] &= 0x7f; // ensure high bit is clear so it's always positive + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function generateServerCert(caKeys: CryptoKeyPair, caCert: x509.X509Certificate) { + const keys = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + + const cert = await x509.X509CertificateGenerator.create({ + serialNumber: generateSerial(), + subject: `CN=${HOSTNAME}`, + issuer: caCert.subject, + notBefore: new Date(), + notAfter: new Date(Date.now() + 30 * 365 * 24 * 60 * 60 * 1000), + signingKey: caKeys.privateKey, + publicKey: keys.publicKey, + signingAlgorithm: EC_ALG, + extensions: [ + new x509.BasicConstraintsExtension(false), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, + ), + new x509.ExtendedKeyUsageExtension(["1.3.6.1.5.5.7.3.1"]), + new x509.SubjectAlternativeNameExtension([ + { type: "dns", value: HOSTNAME }, + { type: "dns", value: "localhost" }, + { type: "ip", value: "127.0.0.1" }, + ]), + await x509.AuthorityKeyIdentifierExtension.create(caKeys.publicKey), + ], + }); + + return { cert, keys }; +} + +async function exportPrivateKeyPem(key: CryptoKey): Promise { + const der = await crypto.subtle.exportKey("pkcs8", key); + const b64 = Buffer.from(der).toString("base64"); + return `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join("\n")}\n-----END PRIVATE KEY-----`; +} + +export async function loadOrCreateCerts() { + const thoriumPath = DataStore.operations.getStore()!.thoriumPath; + const DATA_DIR = path.join(thoriumPath, "certs"); + fs.mkdirSync(DATA_DIR, { recursive: true }); + + const caKeyFile = path.join(DATA_DIR, "ca.key"); + const caCertFile = path.join(DATA_DIR, "ca.crt"); + const serverKeyFile = path.join(DATA_DIR, "server.key"); + const serverCertFile = path.join(DATA_DIR, "server.crt"); + + if (fs.existsSync(caCertFile) && fs.existsSync(serverCertFile) && fs.existsSync(serverKeyFile)) { + const caPem = fs.readFileSync(caCertFile, "utf8"); + const serverCertPem = fs.readFileSync(serverCertFile, "utf8"); + + const serverKeyPem = fs.readFileSync(serverKeyFile, "utf8"); + return { caPem, serverCertPem, serverKeyPem }; + } + + const { cert: caCert, keys: caKeys } = await generateCA(); + fs.writeFileSync(caCertFile, caCert.toString("hex")); + fs.writeFileSync(caKeyFile, await exportPrivateKeyPem(caKeys.privateKey)); + + const { cert: serverCert, keys: serverKeys } = await generateServerCert(caKeys, caCert); + fs.writeFileSync(serverCertFile, serverCert.toString("pem")); + fs.writeFileSync(serverKeyFile, await exportPrivateKeyPem(serverKeys.privateKey)); + + return { + caPem: caCert.toString("pem"), + serverCertPem: serverCert.toString("pem"), + serverKeyPem: await exportPrivateKeyPem(serverKeys.privateKey), + }; +} diff --git a/app/.server/init/exitHandler.ts b/app/.server/init/exitHandler.ts index 48b5cc3a..85b135ed 100644 --- a/app/.server/init/exitHandler.ts +++ b/app/.server/init/exitHandler.ts @@ -1,6 +1,12 @@ -import type { DatabaseContext } from "@thorium/typeguards/isDatabaseContext"; import { DataStore, type DataStoreOperations } from "@thorium/utils/.server/db-fs"; +const exitFunctions = new Set<() => Promise | void>(); + +export function registerExitFunction(fn: () => Promise | void) { + exitFunctions.add(fn); + return () => exitFunctions.delete(fn); +} + export function exitHandler(operations: DataStoreOperations) { if (process.env.NODE_ENV === "production") { process.stdin.resume(); //so the program will not close instantly @@ -8,7 +14,11 @@ export function exitHandler(operations: DataStoreOperations) { async function exitHandler(options: { cleanup?: boolean; exit?: boolean }) { if (options.cleanup) { await DataStore.operations.run(operations, async () => { - await snapshot(operations.database); + for (const fn of exitFunctions) { + try { + await fn(); + } catch {} + } }); } if (options.exit) process.exit(); @@ -28,13 +38,3 @@ export function exitHandler(operations: DataStoreOperations) { process.on("uncaughtException", exitHandler.bind(null, { exit: true })); } } - -export async function snapshot(database: DatabaseContext) { - await database.server.write(true); - await Promise.all( - database.server.plugins.map(async (plugin) => { - await plugin.write(true); - }), - ); - await database.flight?.write(true); -} diff --git a/app/.server/init/mdns.ts b/app/.server/init/mdns.ts new file mode 100644 index 00000000..38195d01 --- /dev/null +++ b/app/.server/init/mdns.ts @@ -0,0 +1,22 @@ +import ciao from "@homebridge/ciao"; +import { registerExitFunction } from "@thorium/.server/init/exitHandler"; +const responder = ciao.getResponder(); + +export async function advertiseMdns(port: number, name: string = "Thorium Nova") { + const service = responder.createService({ + name, + type: "http", + port, + hostname: "thorium.local", + subtypes: ["thorium"], + }); + service.on("name-change", (event) => { + console.log("Name changed", event); + }); + await service.advertise(); + + registerExitFunction(async () => { + await service.destroy(); + }); + return service; +} diff --git a/app/.server/server.cert b/app/.server/server.cert deleted file mode 100644 index a1c5e8d1..00000000 --- a/app/.server/server.cert +++ /dev/null @@ -1,22 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDmDCCAoACCQCa+6EpUPnIojANBgkqhkiG9w0BAQsFADCBjTELMAkGA1UEBhMC -VVMxDTALBgNVBAgMBFV0YWgxFzAVBgNVBAcMDlNhbHQgTGFrZSBDaXR5MRYwFAYD -VQQKDA1GeXJld29ya3MgTExDMRAwDgYDVQQLDAdUaG9yaXVtMQowCAYDVQQDDAEq -MSAwHgYJKoZIhvcNAQkBFhFhbGV4QGZ5cmV3b3Jrcy51czAeFw0yMDAxMDgwMTQy -MzNaFw00MDAxMDMwMTQyMzNaMIGNMQswCQYDVQQGEwJVUzENMAsGA1UECAwEVXRh -aDEXMBUGA1UEBwwOU2FsdCBMYWtlIENpdHkxFjAUBgNVBAoMDUZ5cmV3b3JrcyBM -TEMxEDAOBgNVBAsMB1Rob3JpdW0xCjAIBgNVBAMMASoxIDAeBgkqhkiG9w0BCQEW -EWFsZXhAZnlyZXdvcmtzLnVzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC -AQEA4KgA9LOrb8QbNSOVV5y7M7+NKFCYbQMysNTXLcEPNrlpkTKCAbRaRnXsqJSK -rrSorpYqgwFcSUt95Z91ZkTozOHXv8BQ0hUicULumsSKWGbARbZ603+iqSsHiPTE -r5MUpyBNi+/AqY3SVQp0L3uLBFMo8Xf7A+YJVdjjbsFwoJE4PryBfJeveRBpRa0a -Shou7rSY4cHWpQnhKVhLNFLlJNOWSLnheE+I3620fcuD0ZbVTNtiFs9VT5FdjCJ1 -KEgusQ8QmwSV94y81/fo53Wlr328WZuIYgAhVwEvGjb1GjVeYgW/AkEejsF7VBzG -CTwTxkaojCSx+0iuV538AzPpDwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBT581v -jj8Xw6j0Z2Li8nz9Zq812fIrKIqgNMNCk7B97uwGsEMgBWOkqSGcp+uVGYgBSeCz -V1/unrEIUN9fDnAVhkhOtSXPSbIqCTPqIJ3XvDRuQubn/CkMTqBJyIxilON1fKV4 -mgFKVJVAKtX5AqIkNrq7vtQceM18p4cM+3VqfGV5IGe3W06dwjF5rxR2Ne6xdH0x -pHQuzU+TJF2DVImnV3p7e31yRo0lw5O3M3IN/DJ6DaUvueEzwAABiK6QzHur7nFm -v5TceSMTUFXbZ7/7Y8sf1UUnoxy6Ot7ZzcybRYc40ETY1cwdSt90b/bXu1w7Gi25 -JfCnPuHf22ijb5aY ------END CERTIFICATE----- diff --git a/app/.server/server.key b/app/.server/server.key deleted file mode 100644 index a7723b1f..00000000 --- a/app/.server/server.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDgqAD0s6tvxBs1 -I5VXnLszv40oUJhtAzKw1NctwQ82uWmRMoIBtFpGdeyolIqutKiuliqDAVxJS33l -n3VmROjM4de/wFDSFSJxQu6axIpYZsBFtnrTf6KpKweI9MSvkxSnIE2L78CpjdJV -CnQve4sEUyjxd/sD5glV2ONuwXCgkTg+vIF8l695EGlFrRpKGi7utJjhwdalCeEp -WEs0UuUk05ZIueF4T4jfrbR9y4PRltVM22IWz1VPkV2MInUoSC6xDxCbBJX3jLzX -9+jndaWvfbxZm4hiACFXAS8aNvUaNV5iBb8CQR6OwXtUHMYJPBPGRqiMJLH7SK5X -nfwDM+kPAgMBAAECggEBALyjlZnjvBJvQsFODbqxemercmXltvfidZASSFS9BX9Y -FRK8rHmt7Re5MW0kvYaRYAqpQS0b2VgaMa68RKIJPsm64aS7YaHl6TPdlekTG2+7 -Z7uhzGUHiZgu73bKwNUE1ZS4BSV5vfchhPmve3vbaxHImlqX+oh9gl05b0+y0lWf -x9O6gU4gogoRNEZaZuOinee4ey+OMyWMhWmjfnjJfmpEyEd2Yuj4fZK2pR02NTXp -tvQDOAKv5ymZbNRlfpoWnrih+eobSx4IBiJ4HcZpPCLxC6ujLKwagEIDpF69BImG -qrWvqx9KdzPdtzy5aGCQgLmHMC8zO39WrCrKL8KZVrECgYEA+k+bcNltsFLod1FJ -pSohKGBJ1xaC3rCBSKeUQ5QjEubKrB6KRnpLaDdte1nBGTSNzcUD/vrpUGfWKjxy -3fuPuNrzse35k7D1WUus9nSZqW1jyzSyaUND+u8UekIqHZwbZG2B6ptYpNyCd0D5 -vAM3bCqpfFS0kTKzQyqIgaU3UMMCgYEA5cMhAR2FfdkOovmTtjANabBku6v1nMlM -otyM1BurfBHTbGH1304p6J0RAZGNVzIEJAoWZNJEVaz5QZgBJ3j9O1hxkalcv49O -ZeiwlT91kHFEZxQmcWFWQpN8i3LYC3sb+cR1PLuV+9IZTTYp2pd93ukp0EXAEwBq -kRCHOfFGAcUCgYBRjih+L0mO3qSBY8nT95MQHbdRTjNbrTSiYH9tOVHoMawtQlIZ -PnVdQln4i7jfpoDhX2S66jOkkAB2iHWAoBsmxqgJ/BiKJ2j5LC/xqPH8Avk11zie -FV2L3+PTJcWlGhzOruNadjz+kh4sDs6PlJi1NH0+Vjby7Ar+QlhxrUwFkwKBgBik -fgS1mtytjBFUvU/8BWRBg/xQ7C3dtTd+eihBs3nCG6/zLcC+jGYjenB1oPgqKCGJ -Z2920ddyjuVHHVCTfG2CBeQ+ivEMuLbbZx0Kylwsmc6IbbsZWnwXf3MhZSSOFqkK -NhzGbcateQM7/O8x8lEXJuHsqTYghE18vVQsmXqVAoGBANNEyD3BDszW4ToBVnF8 -/USjhK8yZznBoG7cM6FqLv3DLlM9m/j6buCVcUYmNzQSBhDRynd3egNOTS8BTioA -aSskExJ9H0ifN7LSc0yvshIc2+edPU6X9TJyG9phWfQg/mcC3whDo4mgQ8ShY5gN -F73zYSy8Z6Z++gvO6XY8pJIY ------END PRIVATE KEY----- diff --git a/app/bunServer.ts b/app/bunServer.ts index 510561a8..81659611 100644 --- a/app/bunServer.ts +++ b/app/bunServer.ts @@ -1,22 +1,25 @@ import { readdir } from "node:fs/promises"; -import path from "node:path"; import type LongRangeCommPlugin from "@thorium/.server/classes/Plugins/ShipSystems/LongRangeComm"; import { buildDatabase } from "@thorium/.server/init/buildDatabase"; -import { exitHandler, snapshot } from "@thorium/.server/init/exitHandler"; +import { loadOrCreateCerts } from "@thorium/.server/init/certs"; +import { exitHandler, registerExitFunction } from "@thorium/.server/init/exitHandler"; import { initDefaultPlugin } from "@thorium/.server/init/initDefaultPlugin"; import { createContext, initWebsocket } from "@thorium/.server/init/liveQuery"; +import { advertiseMdns } from "@thorium/.server/init/mdns"; import { router } from "@thorium/.server/init/router"; import { DataStore } from "@thorium/utils/.server/db-fs"; import { bunDataStoreProps, setBasePath } from "@thorium/utils/.server/db-fs/bunDataStoreProps"; import { loadPlugins } from "@thorium/utils/.server/db-fs/loadPlugins"; import { processTriggers } from "@thorium/utils/.server/evaluateEntityQuery"; +import { snapshot } from "@thorium/utils/.server/snapshot"; import { vanity } from "@thorium/utils/.server/vanity"; import { liveQueryPlugin } from "@thorium/utils/live-query/.server/adapters/hono-adapter"; import type { ProcedureCallOptions } from "@thorium/utils/live-query/.server/procedure"; import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { websocket, upgradeWebSocket } from "hono/bun"; +import { getCookie, setCookie } from "hono/cookie"; import { getMimeType } from "hono/utils/mime"; import { isObject } from "./typeguards/isObject"; @@ -62,7 +65,14 @@ export async function startHttpServer({ isProd, isKiosk }: { isProd: boolean; is app.use(middleware); - app.get("/healthcheck", () => new Response("OK", { status: 200 })); + app.get( + "/healthcheck", + () => + new Response("OK", { + status: 200, + headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET" }, + }), + ); app.post("/snapshot", async () => { await snapshot(database); @@ -112,7 +122,32 @@ export async function startHttpServer({ isProd, isKiosk }: { isProd: boolean; is ); }); + let httpsRunning: string | null = null; if (isProd) { + // Automatically redirect to HTTPS if this cookie is set, + // which means the certificate is working correctly in the user's browser + app.use(async (c, next) => { + const url = new URL(c.req.url); + const cookieName = "thorium_https"; + if (httpsRunning && url.protocol === "http:") { + const cookie = getCookie(c, cookieName); + if (cookie === "true") { + url.protocol = "https:"; + return c.redirect(url.toString()); + } + return await next(); + } + if (url.protocol === "https:") { + await next(); + setCookie(c, cookieName, "true", { + httpOnly: true, + maxAge: 34560000, + secure: true, + path: "/", + }); + } + }); + const getClientBundleFile = (await import("./utils/.server/embeddedUtils")) .getClientBundleFile; app.use(async (c) => { @@ -137,58 +172,63 @@ export async function startHttpServer({ isProd, isKiosk }: { isProd: boolean; is exitHandler(dataStoreProps); + registerExitFunction(async () => { + const database = DataStore.operations.getStore()!.database; + await snapshot(database); + }); + + if (isProd) { + const certs = await loadOrCreateCerts(); + app.get( + "/ca.crt", + () => + new Response(certs.caPem, { + headers: { + "Content-Type": "application/x-x509-ca-cert", + "Content-Disposition": 'attachment; filename="ThoriumNova-CA.crt"', + }, + }), + ); + + const tls = { + cert: certs.serverCertPem, + key: certs.serverKeyPem, + }; + + const https = Bun.serve({ + port: 443, + fetch: app.fetch, + websocket, + reusePort: true, + tls, + }); + httpsRunning = https.url.href; + } + const port = Number(process.env.PORT) + (process.env.NODE_ENV === "test" ? 1 : 0) || - (isProd ? Number(process.env.PORT) || 4444 : 3001); + (isProd ? Number(process.env.PORT) || 80 : 3001); const server = Bun.serve({ port, fetch: app.fetch, websocket, reusePort: true, + http3: true, }); + if (isProd) { + await advertiseMdns(server.port!); + } + vanity(); - // This exact string is required for the kiosk to recognize - // that the server has started. console.info(`Server running on ${server.url.href}`); - - if (isProd) { - let tls: { cert: string; key: string } | undefined; - if (isKiosk) { - const bundledFiles = await readdir(import.meta.dirname); - const certFile = bundledFiles.find((f) => f.startsWith("server") && f.endsWith(".cert")); - const keyFile = bundledFiles.find((f) => f.startsWith("server") && f.endsWith(".key")); - if (certFile && keyFile) { - tls = { - cert: await Bun.file(path.join(import.meta.dirname, certFile)).text(), - key: await Bun.file(path.join(import.meta.dirname, keyFile)).text(), - }; - } - } else { - const { certFile, keyFile } = await ( - await import("./utils/.server/embeddedUtils") - ).getSSLCert(); - if (certFile && keyFile) { - tls = { - // TODO: Support user-provided TLS certificates - cert: await certFile.text(), - key: await keyFile.text(), - }; - } - } - if (tls) { - const https = Bun.serve({ - port: port + 1, - fetch: app.fetch, - websocket, - reusePort: true, - tls, - }); - console.info(`HTTPS running on ${https.url.href}`); - } + if (httpsRunning) { + console.info(`HTTPS running on ${httpsRunning}`); + return httpsRunning; } - return `http://${server.hostname}:${server.port}`; + + return server.url.href; }); } catch (error) { console.error("Error Starting Server:", error); diff --git a/app/root.tsx b/app/root.tsx index 09126d75..4c32bfc3 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -9,7 +9,7 @@ import { getBackground } from "@thorium/utils/getBackground"; import "./styles/tailwind.css"; // @ts-expect-error import "@fontsource-variable/outfit"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { isRouteErrorResponse, Links, @@ -104,6 +104,7 @@ function Preload() { } export default function Root() { + useHttpRedirect(); return ( @@ -154,3 +155,17 @@ function Snapshot() { ); } + +function useHttpRedirect() { + useEffect(() => { + if (window.location.protocol === "http:") { + const url = new URL(window.location.href); + url.protocol = "https:"; + fetch(url).then((res) => { + if (res.ok) { + window.location.assign(url); + } + }); + } + }, []); +} diff --git a/app/utils/.server/embeddedUtils.ts b/app/utils/.server/embeddedUtils.ts index 7c1e5b53..8b7ba4c1 100644 --- a/app/utils/.server/embeddedUtils.ts +++ b/app/utils/.server/embeddedUtils.ts @@ -50,25 +50,3 @@ export async function getClientBundleFile(filePath: string) { return null; } - -export async function getSSLCert() { - // @ts-expect-error - await import("../../.server/server.cert", { - with: { type: "file" }, - }); - // @ts-expect-error - await import("../../.server/server.key", { - with: { type: "file" }, - }); - - const certFile = embeddedFiles.find( - // @ts-expect-error Bun adds the file name - (file) => file.name === "server.cert", - ); - const keyFile = embeddedFiles.find( - // @ts-expect-error Bun adds the file name - (file) => file.name === "server.key", - ); - - return { certFile, keyFile }; -} diff --git a/app/utils/.server/mdns/LICENSE b/app/utils/.server/mdns/LICENSE new file mode 100644 index 00000000..b5ad9266 --- /dev/null +++ b/app/utils/.server/mdns/LICENSE @@ -0,0 +1,57 @@ +GNU LESSER GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. + +0. Additional Definitions. +As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License. + +“The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. + +An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. + +A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”. + +The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. + +The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. + +1. Exception to Section 3 of the GNU GPL. +You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. + +2. Conveying Modified Versions. +If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: + +a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or +b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. +3. Object Code Incorporating Material from Library Header Files. +The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: + +a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. +b) Accompany the object code with a copy of the GNU GPL and this license document. +4. Combined Works. +You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: + +a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. +b) Accompany the Combined Work with a copy of the GNU GPL and this license document. +c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. +d) Do one of the following: +0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. +1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. +e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) +5. Combined Libraries. +You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: + +a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. +b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. +6. Revised Versions of the GNU Lesser General Public License. +The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. + diff --git a/app/utils/.server/mdns/README.md b/app/utils/.server/mdns/README.md new file mode 100644 index 00000000..e2e74b2e --- /dev/null +++ b/app/utils/.server/mdns/README.md @@ -0,0 +1,158 @@ +# dns-sd + +Pulled from https://github.com/earthstar-project/dns-sd + +All files in the mdns folder are licensed GNU LGPL. The rest of the Thorium project is still licensed Apache 2.0 + +A module of utilities for DNS-SD (aka Zeroconf, Bonjour, Avahi), featuring: + +- Advertise and browse DNS-SD services over multicast DNS (compliant with + [RFC 6763](https://www.rfc-editor.org/rfc/rfc6763)) +- A generic mDNS continuous querier (compliant with + [RFC 6762](https://www.rfc-editor.org/rfc/rfc6762)) +- A generic mDNS responder (compliant with + [RFC 6762](https://www.rfc-editor.org/rfc/rfc6762)) +- Utilities for encoding and decoding DNS messages to and from `Uint8Array`. +- Works with any JS runtime (e.g. Deno, Node) +- Implemented completely in TypeScript + +## Motivation + +This module was made so that [Earthstar](https://earthstar-project.org) can have +automatic peer discovery on a local network. Earthstar is written in TypeScript, +and can run in browsers, Deno, and Node. + +I made this because there weren't any DNS-SD libraries written in TypeScript +_and_ which had service browsing _and_ service advertising _and_ which could run +on alternative JS runtimes like Deno (theoretically this module can run in the +browser, which is cool — even if it doesn't make any sense). + +I also wanted something that used standard `Uint8Array` instead of Node's +`Buffer` for message encoding and decoding. + +## Usage + +This module works for both Deno and Node. The multicast drivers for each can be +found at the `@earthstar/dns-sd/deno` and `@earthstar/dns-sd/node` entrypoints, +respectively. + +> **NB**: Deno's multicasting APIs have not yet stabilised, so the +> `--unstable-net` flag is required to use this module. + +## API + +### `browse` + +Searches for given DNS-SD services on the local network. Returns an async +iterator of discovered services. + +```ts + + +for await ( + const service of browse({ + multicastInterface: new MulticastInterface(), + service: { + protocol: "tcp", + type: "http", + }, + }) +) { + if (service.isActive) { + + } +} +``` + +### `advertise` + +Advertise a service over multicast DNS. + +Returns a promise which will reject if fifteen failed attempts to claim a name +are made within a ten second interval. + +If the service has to be renamed due to a conflict, a warning with the new name +will be sent to the console. + +```ts +await advertise({ + service: { + host: "10.0.0.7", + name: "My Web Server", + port: 8080, + protocol: "tcp", + type: "http", + txt: { + psswd: "abc123", + }, + }, + multicastInterface: new MulticastInterface(), +}); +``` + +## Advanced API + +These are lower-level APIs for directly working with DNS records. `browse` and +`advertise` are implemented using these. + +### `respond` + +Runs a multicast DNS responder for the given resource records. + +Returns a promise that will reject when: + +- Probing for proposed records fails +- Another responder starts responding with records our responder previously lay + claim to. + +```ts +await respond({ + proposedRecords: [myAAAARecord], + multicastInterface: new MulticastInterface(), +}); +``` + +### `Query` + +A continuous multicast DNS query. + +Reports additions, flushes, and expirations of resource records answering the +given query via an asynchronous iterator: + +```ts +const query = new Query( + [{ name: "_http._tcp.local", recordType: 255 }], + multicastInterface: new MulticastInterface() +); + +for await (const event of query) { + +} +``` + +Also has an `answers` method which returns all records given as answers to this +query, as well as an `additional` method which returns all records found in the +additional section of DNS messages containing answers to our queries. + +### `decodeMessage` + +Decode a DNS message from `Uint8Array`. + +Resource Records of the following types will have their RDATA decoded: `A`, +`PTR`, `TXT`, `AAAA`, `SRV`, `NSEC`. Other types of resource records will have +their RDATA left as `Uint8Array`. + +### `encodeMessage` + +Encode a DNS message as Uint8Array. + +Compresses domain names, so re-encoded messages may come out smaller. + +Will never use the `TC` flag in the header. + +### `MulticastInterface` + +This represents a network interface which has joined a multicast group. When +combined with a `MulticastDriver` it can be made to work with different runtimes +such as Deno and Node. If no driver is provided when this class is instantiated, +it automatically selects a driver appropriate to the current runtime. diff --git a/app/utils/.server/mdns/concat.ts b/app/utils/.server/mdns/concat.ts new file mode 100644 index 00000000..4694fcad --- /dev/null +++ b/app/utils/.server/mdns/concat.ts @@ -0,0 +1,31 @@ +/** + * Concatenate an array of byte slices into a single slice. + * + * @param buffers Array of byte slices to concatenate. + * @returns A new byte slice containing all the input slices concatenated. + * + * @example Basic usage + * ```ts + * import { concat } from "@std/bytes/concat"; + * import { assertEquals } from "@std/assert"; + * + * const a = new Uint8Array([0, 1, 2]); + * const b = new Uint8Array([3, 4, 5]); + * + * assertEquals(concat([a, b]), new Uint8Array([0, 1, 2, 3, 4, 5])); + * ``` + */ +export function concat(buffers: readonly Uint8Array[]): ReturnType { + let length = 0; + for (const buffer of buffers) { + length += buffer.length; + } + const output = new Uint8Array(length); + let index = 0; + for (const buffer of buffers) { + output.set(buffer, index); + index += buffer.length; + } + + return output; +} diff --git a/app/utils/.server/mdns/decode/message_decode.ts b/app/utils/.server/mdns/decode/message_decode.ts new file mode 100644 index 00000000..216185ca --- /dev/null +++ b/app/utils/.server/mdns/decode/message_decode.ts @@ -0,0 +1,392 @@ +import { + type DnsMessage, + type DnsMessageHeader, + type DnsQuestionSection, + type ResourceRecord, + type ResourceRecordTXT, + ResourceType, +} from "./types"; + +/** Decode a DNS message from `Uint8Array`. + * + * Resource Records of the following types will have their RDATA decoded: `A`, `PTR`, `TXT`, `AAAA`, `SRV`, `NSEC`. Other types of resource records will have their RDATA left as `Uint8Array`. + */ +export function decodeMessage(message: Uint8Array): DnsMessage { + const header = decodeHeader(message); + + let position = 12; + + const question: DnsQuestionSection[] = []; + const answer: ResourceRecord[] = []; + const authority: ResourceRecord[] = []; + const additional: ResourceRecord[] = []; + + if (header.QDCOUNT > 0) { + let questionPosition = position; + + for (let currentQuestion = 0; currentQuestion < header.QDCOUNT; currentQuestion++) { + const { result, nextPosition } = decodeQuestion(message, questionPosition); + + question.push(result); + + questionPosition = nextPosition; + } + + position = questionPosition; + } + + if (header.ANCOUNT > 0) { + let answerPosition = position; + + for (let currentAnswer = 0; currentAnswer < header.ANCOUNT; currentAnswer++) { + const { result, nextPosition } = decodeResourceRecord(message, answerPosition); + + answer.push(result); + + answerPosition = nextPosition; + } + + position = answerPosition; + } + + if (header.NSCOUNT > 0) { + let authorityPosition = position; + + for (let currentAuthority = 0; currentAuthority < header.NSCOUNT; currentAuthority++) { + const { result, nextPosition } = decodeResourceRecord(message, authorityPosition); + + authority.push(result); + + authorityPosition = nextPosition; + } + + position = authorityPosition; + } + + if (header.ARCOUNT > 0) { + let additionalPosition = position; + + for (let currentAdditional = 0; currentAdditional < header.ARCOUNT; currentAdditional++) { + const { result, nextPosition } = decodeResourceRecord(message, additionalPosition); + + additional.push(result); + + additionalPosition = nextPosition; + } + + position = additionalPosition; + } + + return { + header, + question, + answer, + authority, + additional, + }; +} + +export function decodeHeader(message: Uint8Array): DnsMessageHeader { + const dataView = new DataView(message.buffer); + const id = dataView.getUint16(0, false); + const flags = dataView.getUint16(2, false); + const qdCount = dataView.getUint16(4, false); + const anCount = dataView.getUint16(6, false); + const nsCount = dataView.getUint16(8, false); + const arCount = dataView.getUint16(10, false); + + return { + ID: id, + QR: (flags & 0x8000) >> 15, + OPCODE: (flags & 0x7800) >> 11, + AA: (flags & 0x400) >> 10, + TC: (flags & 0x200) >> 9, + RD: (flags & 0x100) >> 8, + RA: (flags & 0x80) >> 7, + Z: (flags & 0x40) >> 6, + AD: (flags & 0x20) >> 5, + CD: (flags & 0x10) >> 4, + RCODE: flags & 0xf, + QDCOUNT: qdCount, + ANCOUNT: anCount, + NSCOUNT: nsCount, + ARCOUNT: arCount, + }; +} + +export function decodeQuestion( + message: Uint8Array, + /** The position of this question within its DNS message's bytes */ + startPosition: number, +): { + result: DnsQuestionSection; + /** The position immediately following this question */ + nextPosition: number; +} { + const dataView = new DataView(message.buffer); + + const { labels, nextPosition } = decodeLabels(message, startPosition); + + const qType = dataView.getUint16(nextPosition); + const qClass = dataView.getUint16(nextPosition + 2); + + return { + result: { + QNAME: labels, + QTYPE: qType, + QCLASS: qClass, + }, + nextPosition: nextPosition + 4, + }; +} + +export function decodeResourceRecord( + message: Uint8Array, + /** The position of this question within its DNS message's bytes */ + startPosition: number, +): { + result: ResourceRecord; + /** The position immediately following this resource record */ + nextPosition: number; +} { + const dataView = new DataView(message.buffer); + + const { labels, nextPosition } = decodeLabels(message, startPosition); + + const resourceType = dataView.getUint16(nextPosition); + const resourceClass = dataView.getUint16(nextPosition + 2); + + const resourceTTL = dataView.getUint32(nextPosition + 4); + const rdataLength = dataView.getUint16(nextPosition + 8); + + const decodedData = decodeRdata(resourceType, message, nextPosition + 10, rdataLength); + + return { + result: { + NAME: labels, + TYPE: resourceType, + CLASS: resourceClass & ~0x8000, + TTL: resourceTTL, + RDLENGTH: rdataLength, + RDATA: decodedData, + isUnique: !!(resourceClass & 0x8000), + } as ResourceRecord, + nextPosition: nextPosition + 10 + rdataLength, + }; +} + +export function decodeRdata( + type: ResourceType, + message: Uint8Array, + rdataPosition: number, + rdataLength: number, +): ResourceRecord["RDATA"] { + switch (type) { + case ResourceType.A: { + return [ + message[rdataPosition], + message[rdataPosition + 1], + message[rdataPosition + 2], + message[rdataPosition + 3], + ]; + } + + case ResourceType.PTR: { + return decodeLabels(message, rdataPosition).labels; + } + + case ResourceType.TXT: { + const view = new DataView(message.buffer); + + if (rdataLength === 1) { + return {}; + } + + const attributes: ResourceRecordTXT["RDATA"] = {}; + + let attrPosition = rdataPosition; + + while (attrPosition < rdataPosition + rdataLength) { + /** The length of this attribute, including value (if present) */ + const txtLength = view.getUint8(attrPosition); + + /** To build the attribute name with. */ + let currentAttribute = ""; + /** Whether we've passed the = symbol in this attribute yet. */ + let passedEqual = false; + let value: Uint8Array | true | null = true; + + for (let i = 1; i <= txtLength; i++) { + if (passedEqual) { + // Pull the remaining bytes of this attribute out. + + value = message.subarray(attrPosition + i, attrPosition + txtLength + 1); + + break; + } + + const char = String.fromCharCode(message[attrPosition + i]); + + if (char === "=") { + // Stop building the attribute name here. + passedEqual = true; + // If there won't be a next iteration of this loop + // (and thus no assignment of a value to this attribute) + // then this attribute is something=, indicating a null value + // for this attribute. + value = null; + continue; + } + + if (!passedEqual) { + currentAttribute = currentAttribute + char; + } + } + + attributes[currentAttribute] = value; + + attrPosition = attrPosition + txtLength + 1; + } + + return attributes; + } + + case ResourceType.AAAA: { + const rawView = new DataView(message.buffer); + + const parts: string[] = []; + + for (let i = rdataPosition; i < rdataPosition + rdataLength; i += 2) { + const part = rawView.getUint16(i, false); + + parts.push(part.toString(16)); + } + + return parts + .join(":") + .replace(/(^|:)0(:0)*:0(:|$)/, "$1::$3") + .replace(/:{3,4}/, "::"); + } + + case ResourceType.SRV: { + const view = new DataView(message.buffer); + + return { + priority: view.getUint16(rdataPosition, false), + weight: view.getUint16(rdataPosition + 2, false), + port: view.getUint16(rdataPosition + 4, false), + target: decodeLabels(message, rdataPosition + 6).labels, + }; + } + + case ResourceType.NSEC: { + const view = new DataView(message.buffer); + + const { labels, nextPosition } = decodeLabels(message, rdataPosition); + + const windowBlock = view.getUint8(nextPosition); // + const windowBlockLength = view.getUint8(nextPosition + 1); + + // Ignore rrtypes over 255 (only implementing the restricted form) + // Bitfield length must always be < 32, otherwise skip parsing + if (windowBlock !== 0 || windowBlockLength > 32) { + return message.subarray(rdataPosition, rdataPosition + rdataLength); + } + + const typesList = []; + + for (let i = 0; i < windowBlockLength; i++) { + const mask = view.getUint8(nextPosition + 2 + i); + + if (mask === 0) { + continue; + } + + for (let bit = 0; bit < 8; bit++) { + if (mask & (1 << bit)) { + const rrtype = 8 * i + (7 - bit); + typesList.push(rrtype); + } + } + } + + return { + nextDomainName: labels, + types: typesList, + }; + } + + default: + return message.subarray(rdataPosition, rdataPosition + rdataLength); + } +} + +function decodeLabels( + message: Uint8Array, + startPosition: number, +): { + labels: string[]; + nextPosition: number; +} { + const labels: string[] = []; + + let position = startPosition; + + const view = new DataView(message.buffer); + + while (view.getUint8(position) !== 0) { + const labelLength = view.getUint8(position); + + if (labelLength >= 0xc0 /** 192 */) { + /* + + The pointer takes the form of a two octet sequence: + + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | 1 1| OFFSET | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + + The first two bits are ones. This allows a pointer to be distinguished + from a label, since the label must begin with two zero bits because + labels are restricted to 63 octets or less. (The 10 and 01 combinations + are reserved for future use.) The OFFSET field specifies an offset from + the start of the message (i.e., the first octet of the ID field in the + domain header). A zero offset specifies the first byte of the ID field, + etc. + + */ + + const compressionPointer = (labelLength << 8) + view.getUint8(position + 1) - 0xc000; + + const { labels: compressedLabels } = decodeLabels(message, compressionPointer); + + labels.push(...compressedLabels); + + position += 2; + + return { labels, nextPosition: position }; + } + + if (position + labelLength > message.byteLength) { + throw new Error( + `Expected a string of ${labelLength} bytes length, but there are only ${ + message.byteLength - position + } bytes left.`, + ); + } + + const label: string[] = []; + + for (let i = position + 1; i <= position + labelLength; i++) { + label.push(String.fromCharCode(message[i])); + } + + labels.push(label.join("")); + + position += 1 + labelLength; + + continue; + } + + return { labels, nextPosition: position + 1 }; +} diff --git a/app/utils/.server/mdns/decode/message_encode.ts b/app/utils/.server/mdns/decode/message_encode.ts new file mode 100644 index 00000000..28d6de53 --- /dev/null +++ b/app/utils/.server/mdns/decode/message_encode.ts @@ -0,0 +1,723 @@ +import { concat } from "@thorium/utils/.server/mdns/concat"; + +import { + type DnsMessage, + type DnsMessageHeader, + type DnsQuestionSection, + isResourceRecordA, + isResourceRecordAAAA, + isResourceRecordNSEC, + isResourceRecordPTR, + isResourceRecordSRV, + isResourceRecordTXT, + type LabelSeqOffsets, + type LabelSequence, + type ManyLabelSeqOffsets, + type ResourceRecord, + type ResourceRecordA, + type ResourceRecordNSEC, + type ResourceRecordSRV, +} from "./types"; + +/** Encode a DNS message as Uint8Array. + * + * Compresses domain names, so re-encoded messages may come out smaller. + * + * Will never use the `TC` flag in the header. + */ +export function encodeMessage(msg: DnsMessage): Uint8Array { + // Encode header + const header = encodeHeader(msg.header); + + // Extract all labels for compression. + + const questionLabels = msg.question.map((q) => q.QNAME); + const answerLabels = msg.answer.flatMap(extractResourceDataLabels); + const authorityLabels = msg.authority.flatMap(extractResourceDataLabels); + const additionalLabels = msg.additional.flatMap(extractResourceDataLabels); + + const allLabels = [...questionLabels, ...answerLabels, ...authorityLabels, ...additionalLabels]; + + const compressedLabels = compressLabels(allLabels); + + const messageBytes: Uint8Array[] = []; + + /* + + Here's where things start to get squirrely. + We know which labels in the message can be compressed, + but we don't know their offsets in the final message yet + because the message has not yet been written! + + So every time we encode some labels, we get back their written offsets + within the message, which are used by subsequent encodings of messages. + */ + + let manyLabelOffsets: ManyLabelSeqOffsets = {}; + let offset = header.byteLength; + + // Encode question section + for (let i = 0; i < msg.question.length; i++) { + const question = msg.question[i]; + + const { bytes, encodedLabelOffsets } = encodeQuestion( + question, + compressedLabels[i], + manyLabelOffsets, + offset, + ); + + offset += bytes.byteLength; + messageBytes.push(bytes); + // Take the offsets of encoded labels and put them in our offsets record + // The index used is the position of the label sequence within the DNS message. + manyLabelOffsets = { + ...manyLabelOffsets, + [i]: encodedLabelOffsets, + }; + } + + const allResourceRecords = [...msg.answer, ...msg.authority, ...msg.additional]; + + /* + + Another fiddly thing. Each Resource record has a label sequence in its NAME + but depending on the type of resource record, its RDATA may also have a label sequence too! + So we need to manually maintain this index to make a meaningful map of label offsets. + + We will also use this index to access the compressed labels we generated at the start + of this function call. + + */ + + let labelSeqIdx = msg.question.length; + + for (let i = 0; i < allResourceRecords.length; i++) { + const rr = allResourceRecords[i]; + + // Encode all of the resource record except for the RDATA. + // This is because the RDATA may have compressed labels in it... + // ... which may point to labels written in the record's name! + // So we need to have encoded that first and saved position. + const { bytes: mostRRBytes, encodedLabelOffsets } = encodeResourceRecord( + rr, + compressedLabels[labelSeqIdx], + manyLabelOffsets, + offset, + ); + + offset += mostRRBytes.byteLength; + messageBytes.push(mostRRBytes); + // Put the offsets of freshly encoded labels in the label offset map... + manyLabelOffsets = { + ...manyLabelOffsets, + [labelSeqIdx]: encodedLabelOffsets, + }; + + labelSeqIdx += 1; + + let rdataBytes: Uint8Array; + + if (isResourceRecordA(rr)) { + rdataBytes = encodeRdataA(rr); + } else if (isResourceRecordPTR(rr)) { + // PTR can have compressed labels in it! + // Get the (possibly compressed) label sequence for this RDATA + const labelSeq = compressedLabels[labelSeqIdx]; + + const { bytes, encodedLabelOffsets } = encodeRdataPTR(labelSeq, manyLabelOffsets, offset); + + rdataBytes = bytes; + + // Put offsets of encoded labels into the label offset map... + manyLabelOffsets = { + ...manyLabelOffsets, + [labelSeqIdx]: encodedLabelOffsets, + }; + labelSeqIdx += 1; + } else if (isResourceRecordTXT(rr)) { + rdataBytes = encodeRdataTXT(rr.RDATA); + } else if (isResourceRecordAAAA(rr)) { + rdataBytes = encodeRdataAAAA(rr.RDATA); + } else if (isResourceRecordSRV(rr)) { + // SRV can have compressed labels in it! + // Get the (possibly compressed ) label sequence for this RDATA + const labelSeq = compressedLabels[labelSeqIdx]; + + const { bytes, encodedLabelOffsets } = encodeRdataSRV(rr, labelSeq, manyLabelOffsets, offset); + + rdataBytes = bytes; + // Put offsets of encoded labels into the label offset map... + manyLabelOffsets = { + ...manyLabelOffsets, + [labelSeqIdx]: encodedLabelOffsets, + }; + labelSeqIdx += 1; + } else if (isResourceRecordNSEC(rr)) { + // NSEC can have compressed labels in it! + // Get the (possibly compressed ) label sequence for this RDATA + const labelSeq = compressedLabels[labelSeqIdx]; + + const { bytes, encodedLabelOffsets } = encodeRdataNSEC( + rr, + labelSeq, + manyLabelOffsets, + offset, + ); + + rdataBytes = bytes; + // Put offsets of encoded labels into the label offset map... + manyLabelOffsets = { + ...manyLabelOffsets, + [labelSeqIdx]: encodedLabelOffsets, + }; + labelSeqIdx += 1; + } else { + // The data is already Uint8Array, just shove it in there. + rdataBytes = rr.RDATA; + } + + // One last thing! We might have changed the RDLENGTH of the given message with our compression. + // So we need to update that to reflect the RDATA's new length; + if (rr.RDLENGTH !== rdataBytes.byteLength) { + updateRdataLength(mostRRBytes, rdataBytes.byteLength); + } + + offset += rdataBytes.byteLength; + messageBytes.push(rdataBytes); + } + + return concat([header, ...messageBytes]); +} + +function encodeHeader(header: DnsMessageHeader): Uint8Array { + const ui8 = new Uint8Array(12); + const view = new DataView(ui8.buffer); + + // Set the ID + + view.setUint16(0, header.ID); + + // Set the flags + + let flags = 0; + + flags += (header.QR << 15) & 0x8000; + flags += (header.OPCODE << 11) & 0x7800; + flags += (header.AA << 10) & 0x400; + flags += (header.TC << 9) & 0x200; + flags += (header.RD << 8) & 0x100; + flags += (header.RA << 7) & 0x80; + flags += (header.Z << 6) & 0x40; + flags += (header.AD << 5) & 0x20; + flags += (header.CD << 4) & 0x10; + flags += header.RCODE & 0xf; + + view.setUint16(2, flags); + + // Set the counts + + view.setUint16(4, header.QDCOUNT); + view.setUint16(6, header.ANCOUNT); + view.setUint16(8, header.NSCOUNT); + view.setUint16(10, header.ARCOUNT); + + return ui8; +} + +/** Takes a series of a label sequences and iterates through them, determining whether the suffix of the current label sequence has appeared before, and pointing to a previous label sequence if so. + * ``` + * [i, like, cheese] + * [you, love, cheese] + * [we, love, cheese] + * ``` + * + * becomes + * + * ``` + * (labels: [i, like, cheese], pointer: null) + * (labels: [you, love], pointer: [0, 2]) + * (labels: [we], pointer: [1, 1]) + * ``` + */ +function compressLabels(labels: string[][]): LabelSequence[] { + const compressedLabels: LabelSequence[] = []; + + // This is a very naive algorithm which could doubtlessly be made more efficient. + + // For each name to be added to the message + for (let i = 0; i < labels.length; i++) { + // For each suffix of the name... + + // For each suffix already in the message... + const prevLabels = labels.slice(0, i); + + const current = labels[i]; + + if (prevLabels.length === 0) { + compressedLabels.push({ + labels: current, + pointer: null, + }); + continue; + } + + let didCompress = false; + + // Work backwards through all previous label sequences before this one + for (let i = prevLabels.length - 1; i >= 0; i--) { + const prev = prevLabels[i]; + + // If the suffixes match + const currentReversed = [...current].reverse(); + const prevReversed = [...prev].reverse(); + + /** This is the offset of the label we'll point to within its own label sequence */ + let compressionOffset = null; + /** This is how many labels we managed to compress out of this label sequence */ + let compressedLength = 0; + + // Work backwards through the two label sequences, + // comparing each label to see if they have a matching suffix. + for (let seqIdx = 0; seqIdx <= prevReversed.length - 1; seqIdx++) { + const currLabel = currentReversed[seqIdx]; + const prevLabel = prevReversed[seqIdx]; + + if (currLabel !== prevLabel) { + // The suffix doesn't match from here on out, bail. + break; + } + + compressionOffset = prev.length - seqIdx - 1; + compressedLength = seqIdx + 1; + } + + if (compressionOffset && compressedLabels[i].labels[compressionOffset] === undefined) { + // This can happen if the label sequence we're pointing to + // has been compressed itself, so this offset points to nothing! + // So what we'll do is go to the next one, and eventually find + // the canonical label. + continue; + } + + if (compressionOffset !== null && compressedLabels[i].labels[compressionOffset]) { + compressedLabels.push({ + // Only put in the labels we couldn't compress + labels: current.slice(0, current.length - compressedLength), + // Point at the label we'll actually use + pointer: [i, compressionOffset], + }); + didCompress = true; + break; + } + } + + // Couldn't find any compressable labels, just add our ordinary labels with no pointer. + if (didCompress === false) { + compressedLabels.push({ + labels: current, + pointer: null, + }); + } + } + + return compressedLabels; +} + +function encodeQuestion( + /** The question to encode. */ + question: DnsQuestionSection, + /** The label sequence of this question, with optional pointer. */ + labelSequence: LabelSequence, + /** The offsets of previously encoded labels. */ + manyLabelOffsets: ManyLabelSeqOffsets, + questionOffset: number, +): { + /** The encoded question */ + bytes: Uint8Array; + /** The **absolute** offsets of label sequences in the encoded message. */ + encodedLabelOffsets: LabelSeqOffsets; +} { + // get label bytes + const { bytes: labelBytes, newLabelOffsets } = encodeLabelSequence( + labelSequence, + manyLabelOffsets, + ); + + const typeClassBytes = new Uint8Array(4); + const typeClassDataView = new DataView(typeClassBytes.buffer); + + typeClassDataView.setUint16(0, question.QTYPE); + typeClassDataView.setUint16(2, question.QCLASS); + + return { + bytes: concat([labelBytes, typeClassBytes]), + encodedLabelOffsets: offsetPositions(newLabelOffsets, questionOffset), + }; +} + +/** Encodes *most* of a Resource record, just not the RDATA. */ +function encodeResourceRecord( + /** The resource record to be encoded. */ + resourceRecord: ResourceRecord, + /** The label sequence (with optional pointer) of the record's NAME */ + nameLabelSeq: LabelSequence, + /** The offsets of previously encoded labels. */ + manyLabelSeqOffsets: ManyLabelSeqOffsets, + offset: number, +): { + /** The encoded resource record (excluding RDATA) */ + bytes: Uint8Array; + /** The **absolute** position of the encoded label in the bytes*/ + encodedLabelOffsets: LabelSeqOffsets; +} { + // get label bytes + + const { bytes: labelBytes, newLabelOffsets } = encodeLabelSequence( + nameLabelSeq, + manyLabelSeqOffsets, + ); + + const otherBytes = new Uint8Array(10); + const otherDataView = new DataView(otherBytes.buffer); + + const classWithCacheFlushBit = resourceRecord.isUnique + ? resourceRecord.CLASS | 0x8000 + : resourceRecord.CLASS; + + otherDataView.setUint16(0, resourceRecord.TYPE); + otherDataView.setUint16(2, classWithCacheFlushBit); + otherDataView.setUint32(4, resourceRecord.TTL); + otherDataView.setUint16(8, resourceRecord.RDLENGTH); + + return { + bytes: concat([labelBytes, otherBytes]), + encodedLabelOffsets: offsetPositions(newLabelOffsets, offset), + }; +} + +export function encodeRdataA(resourceRecord: ResourceRecordA): Uint8Array { + const aRecordBytes = new Uint8Array(4); + + // e.g. 192.168.0.0 + + for (let i = 0; i < 4; i++) { + aRecordBytes[i] = resourceRecord.RDATA[i]; + } + + return aRecordBytes; +} + +function encodeRdataPTR( + labelSeq: LabelSequence, + manyLabelSeqOffsets: ManyLabelSeqOffsets, + offset: number, +): { + bytes: Uint8Array; + encodedLabelOffsets: LabelSeqOffsets; +} { + const { bytes, newLabelOffsets } = encodeLabelSequence(labelSeq, manyLabelSeqOffsets); + + return { + bytes, + encodedLabelOffsets: offsetPositions(newLabelOffsets, offset), + }; +} + +export function encodeRdataTXT(attributes: Record): Uint8Array { + if (Object.keys(attributes).length === 0) { + // Empty TXT record. Technically invalid, but we don't validate here. + return new Uint8Array(1); + } + + const attributesBytes: Uint8Array[] = []; + + for (const key in attributes) { + const value = attributes[key]; + + const keyChars = Array.from(key); + + const keyCharsByteLength = new TextEncoder().encode(keyChars.join("")).length; + + /** The length of this whole attribute segment (including value) */ + const attrLength = + value === true + ? // Then it's only as long as the key + keyCharsByteLength + : value === null + ? // Then it's as long as the key with an = + keyCharsByteLength + 1 + : // Then it's as long as the key with an = and the value + keyCharsByteLength + 1 + value.byteLength; + + /** The length of the Uint8 preceding the value bytes. */ + const ui8Length = + value === true + ? // Then it's only as long as the key + keyCharsByteLength + : keyCharsByteLength + 1; + + const attrBytes = new Uint8Array(1 + ui8Length); + const attrView = new DataView(attrBytes.buffer); + + attrView.setUint8(0, attrLength); + + let position = 1; + + // Set attribute name characters. + for (const char of keyChars) { + attrView.setUint8(position, char.charCodeAt(0)); + position += 1; + } + + if (value === null) { + attrView.setUint8(position, "=".charCodeAt(0)); + + attributesBytes.push(attrBytes); + } else if (value instanceof Uint8Array) { + attrView.setUint8(position, "=".charCodeAt(0)); + + attributesBytes.push(concat([attrBytes, value])); + continue; + } else { + attributesBytes.push(attrBytes); + } + } + + return concat(attributesBytes); +} + +export function encodeRdataAAAA(ipv6Addr: string): Uint8Array { + const addr = "0" + ipv6Addr; + const parts = addr.split(":"); + + for (let i = 0; i < parts.length; i++) { + const sedectet = parts[i]; + + if (!sedectet) { + parts[i] = "0"; + while (parts.length < 8) { + parts.splice(i + 1, 0, "0"); + } + } + } + + const bytes = new Uint8Array(16); + const dataView = new DataView(bytes.buffer); + + for (let i = 0; i < parts.length; i++) { + dataView.setUint16(i * 2, parseInt(parts[i], 16)); + } + + return bytes; +} + +function encodeRdataSRV( + record: ResourceRecordSRV, + labelSeq: LabelSequence, + manyLabelSeqOffsets: ManyLabelSeqOffsets, + offset: number, +): { + bytes: Uint8Array; + encodedLabelOffsets: LabelSeqOffsets; +} { + const srvBytes = new Uint8Array(6); + const srvView = new DataView(srvBytes.buffer); + + srvView.setUint16(0, record.RDATA.priority); + srvView.setUint16(2, record.RDATA.weight); + srvView.setUint16(4, record.RDATA.port); + + const { bytes: labelBytes, newLabelOffsets } = encodeLabelSequence(labelSeq, manyLabelSeqOffsets); + + return { + bytes: concat([srvBytes, labelBytes]), + encodedLabelOffsets: offsetPositions(newLabelOffsets, 6 + offset), + }; +} + +function encodeRdataNSEC( + record: ResourceRecordNSEC, + labelSeq: LabelSequence, + manyLabelSeqOffsets: ManyLabelSeqOffsets, + offset: number, +): { + bytes: Uint8Array; + encodedLabelOffsets: LabelSeqOffsets; +} { + const { bytes: labelBytes, newLabelOffsets } = encodeLabelSequence(labelSeq, manyLabelSeqOffsets); + + const rrTypes = [...new Set(record.RDATA.types)].filter((x) => x <= 255); + + const maskLength = rrTypes ? Math.ceil(Math.max(...rrTypes) / 8) : 0; + + const masks = Array(maskLength).fill(0); + + for (const type of rrTypes) { + const index = ~~(type / 8); // which mask this rrtype is on + const bit = 7 - (type % 8); // convert to network bit order + + masks[index] |= 1 << bit; + } + + const maskBytes = new Uint8Array(2 + masks.length); + const maskView = new DataView(maskBytes.buffer); + + maskView.setUint8(0, 0); + maskView.setUint8(1, maskLength); + + for (let i = 0; i < masks.length; i++) { + const mask = masks[i]; + + maskView.setUint8(i + 2, mask); + } + + return { + bytes: concat([labelBytes, maskBytes]), + encodedLabelOffsets: offsetPositions(newLabelOffsets, offset), + }; +} + +/** Encodes a sequence of labels, adding compression pointers if necessary. */ +function encodeLabelSequence( + /** The label sequence to be encoded. */ + labelSequence: LabelSequence, + /** A multi-dimensional record of the positions of previously encoded label sequences.*/ + manyLabelOffsets: ManyLabelSeqOffsets, +): { + /** The encoded label sequence. */ + bytes: Uint8Array; + /** The byte offsets of encoded labels, relative to the returned encoded label sequence. */ + newLabelOffsets: LabelSeqOffsets; +} { + if (labelSequence.labels.length === 0) { + // TODO: handle 0-length string + } + + /** How long all of the labels in this sequence will be in bytes */ + const labelsLength = labelSequence.labels.reduce((prev, next) => { + // Add an extra 1 for the length byte + return prev + 1 + next.length; + }, 0); + + /** The length of a pointer in bytes */ + const pointerLength = labelSequence.pointer ? 2 : 0; + /** The length of the terminator, if present */ + const terminatorLength = labelSequence.pointer ? 0 : 1; + + const bytes = new Uint8Array(labelsLength + pointerLength + terminatorLength); + const dataView = new DataView(bytes.buffer); + + let position = 0; + + const newLabelPositions: LabelSeqOffsets = {}; + + for (let i = 0; i < labelSequence.labels.length; i++) { + newLabelPositions[i] = position; + + const label = labelSequence.labels[i]; + + // First byte is length + dataView.setUint8(position, label.length); + + position += 1; + + // Followed by label characters + for (let charIdx = 0; charIdx < label.length; charIdx++) { + dataView.setUint8(position, label[charIdx].charCodeAt(0)); + position += 1; + } + } + + if (labelSequence.pointer) { + const [i1, i2] = labelSequence.pointer; + + const offset = manyLabelOffsets[i1][i2]; + + /* + + The pointer takes the form of a two octet sequence: + + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | 1 1| OFFSET | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + + The first two bits are ones. This allows a pointer to be distinguished + from a label, since the label must begin with two zero bits because + labels are restricted to 63 octets or less. (The 10 and 01 combinations + are reserved for future use.) The OFFSET field specifies an offset from + the start of the message (i.e., the first octet of the ID field in the + domain header). A zero offset specifies the first byte of the ID field, + etc. + + */ + + dataView.setUint16(position, 0xc000 + offset); + + position += 2; + } else { + dataView.setUint8(position, 0); + } + + return { + bytes, + newLabelOffsets: newLabelPositions, + }; +} + +function extractResourceDataLabels(rr: ResourceRecord): string[][] { + // if ptr + if (isResourceRecordPTR(rr)) { + return [rr.NAME, rr.RDATA]; + } + + if (isResourceRecordSRV(rr)) { + return [rr.NAME, rr.RDATA.target]; + } + + if (isResourceRecordNSEC(rr)) { + return [rr.NAME, rr.RDATA.nextDomainName]; + } + + return [rr.NAME]; +} + +/** Offset all the ...offsets... by a given number*/ +function offsetPositions(labelPositions: LabelSeqOffsets, offset: number) { + const newPositions: LabelSeqOffsets = {}; + + for (const key in labelPositions) { + newPositions[key] = labelPositions[key] + offset; + } + + return newPositions; +} + +function updateRdataLength(bytes: Uint8Array, newLength: number) { + const view = new DataView(bytes.buffer); + + let offset = null; + let pos = 0; + + // Find the end of the variable-length label sequence + while (offset === null) { + const length = view.getUint8(pos); + + // End of the label sequence + if (length === 0) { + offset = pos + 1; + } + + // Compression marker + if (length >= 192) { + offset = pos + 2; + } + + // Another length thing + pos += length + 1; + } + + view.setUint16(offset + 8, newLength); + + return bytes; +} diff --git a/app/utils/.server/mdns/decode/types.ts b/app/utils/.server/mdns/decode/types.ts new file mode 100644 index 00000000..5c67bcca --- /dev/null +++ b/app/utils/.server/mdns/decode/types.ts @@ -0,0 +1,311 @@ +export enum Flag { + Disabled = 0, + Enabled, +} + +export enum DnsClass { + /** The Internet */ + IN = 1, + /** The CSNET class (obsolete) */ + CS, + /** The CHAOS class*/ + CH, + /** Hesiod [Dyer 87] */ + HS, + /** Any*/ + ANY = 255, +} + +/* HEADER - see RFC 1035 section 4.1.1 + + 1 1 1 1 1 1 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| ID | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +|QR| Opcode |AA|TC|RD|RA| Z | RCODE | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| QDCOUNT | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| ANCOUNT | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| NSCOUNT | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| ARCOUNT | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + +*/ + +enum OpcodeFlag { + /** A standard query. */ + Query = 0, + /** An inverse query. */ + IQuery, + /** A server status request. */ + Status, + /** Reserved for future use. */ + Reserved, +} + +export enum RcodeFlag { + /** No error condition. */ + NoError = 0, + /** The name server was unable to interpret the query. */ + FormatError, + /** The name server was unable to process the query due to a problem with the name server. */ + ServerFailure, + /** Meaningful only for responses from an authoritative name server, this code signifies that the domain name referenced in the query does not exist. */ + NameError, + /** The name server does not support the requested kind of query. */ + NotImplemented, + /** The name server refuses to perform the specified operation for policy reasons. */ + Refused, + /** Reserved for future use. */ + Reserved, +} + +export type DnsMessageHeader = { + /** A 16 bit identifier assigned by the program. Copied to corresponding replies.*/ + ID: number; + /** Is this message a query or a response? 0 is Query, 1 is Response. */ + QR: Flag; + /** The kind of query in this message. */ + OPCODE: OpcodeFlag; + /** Whether the responding name server is an authority for the domain in question. */ + AA: Flag; + /** Whether this message was truncated or not. */ + TC: Flag; + /** Whether query recursion is desired. */ + RD: Flag; + /** Whether recursion is available or not. */ + RA: Flag; + /** Reserved for future use. */ + Z: Flag; + /** Whether the data included has been verified by the server providing it. */ + AD: Flag; + /** Whether non-verified data is acceptable to the resolver sending the query. */ + CD: Flag; + /** Response code. */ + RCODE: RcodeFlag; + /** The number of entries in the question section */ + QDCOUNT: number; + /** The number of entries in the answer section */ + ANCOUNT: number; + /** The number of name server resource records in the the authority records section. */ + NSCOUNT: number; + /** The number of resource records in the additional records section. */ + ARCOUNT: number; +}; + +/* QUESTION SECTION see RFC 1035 section 4.1.2 + + 1 1 1 1 1 1 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| | +/ QNAME / +/ / ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| QTYPE | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| QCLASS | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + +*/ + +/** A DNS Resource Record type. Only record types relevant to multicast DNS are described here. */ +export enum ResourceType { + /** a host address */ + A = 1, + /** a domain name pointer */ + PTR = 12, + /** text strings */ + TXT = 16, + /** A IPv6 host address*/ + AAAA = 28, + /** A service */ + SRV = 33, + /** Authenticated proof of the non-existence of DNS owner names and types */ + NSEC = 47, + /** A request for any records */ + ANY = 255, +} + +export type DnsQuestionSection = { + /** A domain name represented as a sequence of labels */ + QNAME: string[]; + /** The type of the query. */ + QTYPE: ResourceType; + /** The class of the query */ + QCLASS: DnsClass; +}; + +/* Resource record format - see RFC 1035 section 4.1.3 + + 1 1 1 1 1 1 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| | +/ / +/ NAME / +| | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| TYPE | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| CLASS | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| TTL | +| | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +| RDLENGTH | ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--| +/ RDATA / +/ / ++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +*/ + +export interface ResourceRecordUnknown { + /** A domain name represented as a sequence of labels */ + NAME: string[]; + /** The type of the answer. */ + TYPE: ResourceType; + /** The class of the answer */ + CLASS: DnsClass; + /** Length of time **in seconds** this record should be considered valid for. */ + TTL: number; + /** Length of resource data */ + RDLENGTH: number; + /** Resource data */ + RDATA: unknown; + /** Whether the cache-flush bit was set on this record */ + isUnique: boolean; +} + +export interface ResourceRecordA extends ResourceRecordUnknown { + TYPE: ResourceType.A; + /** IP address */ + RDATA: number[]; +} + +export interface ResourceRecordPTR extends ResourceRecordUnknown { + TYPE: ResourceType.PTR; + /** A label sequence, e.g. [my, blog, com]*/ + RDATA: string[]; +} + +export interface ResourceRecordTXT extends ResourceRecordUnknown { + TYPE: ResourceType.TXT; + RDATA: Record< + string, + /** Uint8Array is a defined value. `true` indicates presence of attribute with no value. `null` indicates presence of attribute with empty value. */ + Uint8Array | true | null + >; +} + +export interface ResourceRecordAAAA extends ResourceRecordUnknown { + TYPE: ResourceType.AAAA; + /** IPv6 address */ + RDATA: string; +} + +export interface ResourceRecordSRV extends ResourceRecordUnknown { + TYPE: ResourceType.SRV; + RDATA: { + priority: number; + weight: number; + port: number; + /** A label sequence, e.g. [my, blog, com]*/ + target: string[]; + }; +} + +export interface ResourceRecordNSEC extends ResourceRecordUnknown { + TYPE: ResourceType.NSEC; + RDATA: { + /** A label sequence, e.g. [my, blog, com]*/ + nextDomainName: string[]; + types: number[]; + }; +} + +export interface ResourceRecordAny extends ResourceRecordUnknown { + RDATA: Uint8Array; +} + +export type ResourceRecord = + | ResourceRecordA + | ResourceRecordPTR + | ResourceRecordTXT + | ResourceRecordAAAA + | ResourceRecordSRV + | ResourceRecordNSEC + | ResourceRecordAny; + +/* + + +---------------------+ + | Header | + +---------------------+ + | Question | the question for the name server + +---------------------+ + | Answer | RRs answering the question + +---------------------+ + | Authority | RRs pointing toward an authority + +---------------------+ + | Additional | RRs holding additional information + +---------------------+ + +*/ + +/** A parsed DNS message. */ +export type DnsMessage = { + header: DnsMessageHeader; + /** Question(s) for DNS responders */ + question: DnsQuestionSection[]; + /** Resource records answering queries */ + answer: ResourceRecord[]; + /** Resource records pointing towards an authority. */ + authority: ResourceRecord[]; + /** Resource records related to this message. */ + additional: ResourceRecord[]; +}; + +// Some compression related types + +/** A series of labels which may or may not end with a pointer. */ +export type LabelSequence = { + labels: string[]; + pointer: null | [number, number]; +}; + +/** A record of byte offsets of a label indexed by its position within a label sequence.*/ +export type LabelSeqOffsets = Record; + +/** Nested records, first depth represents the index of a set of labels, second depth represents the position within that sequence. */ +export type ManyLabelSeqOffsets = Record; + +// The area of js-has-no-pattern-matching shame + +export function isResourceRecordA(rr: ResourceRecord): rr is ResourceRecordA { + return rr.TYPE === ResourceType.A; +} + +export function isResourceRecordPTR(rr: ResourceRecord): rr is ResourceRecordPTR { + return rr.TYPE === ResourceType.PTR; +} + +export function isResourceRecordTXT(rr: ResourceRecord): rr is ResourceRecordTXT { + return rr.TYPE === ResourceType.TXT; +} + +export function isResourceRecordAAAA(rr: ResourceRecord): rr is ResourceRecordAAAA { + return rr.TYPE === ResourceType.AAAA; +} + +export function isResourceRecordSRV(rr: ResourceRecord): rr is ResourceRecordSRV { + return rr.TYPE === ResourceType.SRV; +} + +export function isResourceRecordNSEC(rr: ResourceRecord): rr is ResourceRecordNSEC { + return rr.TYPE === ResourceType.NSEC; +} diff --git a/app/utils/.server/mdns/dns_sd/browse.ts b/app/utils/.server/mdns/dns_sd/browse.ts new file mode 100644 index 00000000..176936a6 --- /dev/null +++ b/app/utils/.server/mdns/dns_sd/browse.ts @@ -0,0 +1,359 @@ +import { + isResourceRecordA, + isResourceRecordAAAA, + isResourceRecordPTR, + isResourceRecordSRV, + isResourceRecordTXT, + type ResourceRecord, + type ResourceRecordA, + type ResourceRecordAAAA, + type ResourceRecordPTR, + type ResourceRecordSRV, + type ResourceRecordTXT, + ResourceType, +} from "../decode/types"; +import { FastFIFO } from "../fast_fifo"; +import { MulticastInterface } from "../mdns/multicast_interface"; +import { type MdnsQuestion, Query } from "../mdns/query"; + +export type BrowseOpts = { + /** The kind of service you wish to browse. */ + service: { + type: string; + protocol: "tcp" | "udp"; + subtypes?: string[]; + }; + multicastInterface: MulticastInterface; + signal?: AbortSignal; +}; + +/** A service discovered via multicast DNS-SD. */ +export type Service = { + name: string; + type: string; + subtypes: string[]; + protocol: "tcp" | "udp"; + host: string; + hostname: string; + port: number; + txt: Record; + /** Whether the service is active or not. Will be `false` when a service responder has indicated it is going offline. */ + isActive: boolean; +}; + +/** Searches for DNS-SD services on the local network. + * + * ``` + * for await ( + * const service of browse({ + * multicastInterface: new MulticastInterface(), + * service: { + * protocol: "tcp", + * type: "http", + * }, + * }) + * ) { + * if (service.isActive) { + * } + * } + * ``` + */ +export function browse(opts: BrowseOpts): AsyncIterable { + const subName = `${ + opts.service.subtypes && opts.service.subtypes.length > 0 + ? `${opts.service.subtypes.map((sub) => `_${sub}`).join(".")}._sub.` + : "" + }`; + + const serviceName = `${subName}_${opts.service.type}._${opts.service.protocol}.local`; + + const questions: MdnsQuestion[] = [ + { + name: serviceName, + recordType: ResourceType.PTR, + }, + ]; + + const ptrQuery = new Query(questions, opts.multicastInterface); + + if (opts.signal) { + opts.signal.addEventListener("abort", () => { + ptrQuery.end(); + }); + } + + const fifo = new FastFIFO(16); + + const services = new Map(); + + (async () => { + for await (const event of ptrQuery) { + switch (event.kind) { + case "ADDED": + // We need to add only one + if (isResourceRecordPTR(event.record)) { + // iterate over a service thing and relay its events. + const key = event.record.RDATA.join("."); + + if (services.has(key)) { + continue; + } + + const service = new ServiceResolver( + event.record, + ptrQuery.additional(), + opts.multicastInterface, + ); + + for await (const event of service) { + fifo.push(event); + } + + services.set(key, service); + } + break; + case "EXPIRED": + if (isResourceRecordPTR(event.record)) { + const key = event.record.RDATA.join("."); + + const service = services.get(key); + + if (!service) { + continue; + } + + service.close(); + services.delete(key); + } + } + } + })(); + + return fifo; +} + +class ServiceResolver { + private srvRecord: ResourceRecordSRV | null = null; + private txtRecord: ResourceRecordTXT | null = null; + private multicastInterface: MulticastInterface; + private hostnameQuery: Query | null = null; + private hostnameRecord: ResourceRecordA | ResourceRecordAAAA | null = null; + private fifo = new FastFIFO(16); + private serviceName: string; + + constructor( + ptrRecord: ResourceRecordPTR, + additionalRecords: ResourceRecord[], + multicastInterface: MulticastInterface, + ) { + this.multicastInterface = multicastInterface; + + // Get SRV and text records. + + this.serviceName = ptrRecord.RDATA.join("."); + + const srvRecord = additionalRecords.find((record) => { + return ( + record.NAME.join(".").toUpperCase() === ptrRecord.RDATA.join(".").toUpperCase() && + record.TYPE === ResourceType.SRV + ); + }); + + const txtRecord = additionalRecords.find((record) => { + return ( + record.NAME.join(".").toUpperCase() === ptrRecord.RDATA.join(".").toUpperCase() && + record.TYPE === ResourceType.TXT + ); + }); + + const hostNameResourceType = + this.multicastInterface.family === "IPv4" ? ResourceType.A : ResourceType.AAAA; + + const aRecord = additionalRecords.find((record) => { + return ( + record.NAME.join(".").toUpperCase() === + (srvRecord as ResourceRecordSRV)?.RDATA.target.join(".").toUpperCase() && + record.TYPE === hostNameResourceType + ); + }); + + if (txtRecord) { + this.resolveTxt(txtRecord as ResourceRecordTXT); + } + + // Ask for srv, txt if we don't have it + if (!srvRecord || !txtRecord) { + this.resolvePtr(); + } else if (srvRecord && !aRecord) { + this.resolveSrv(srvRecord as ResourceRecordSRV); + } else if (srvRecord && aRecord) { + this.srvRecord = srvRecord as ResourceRecordSRV; + this.hostnameRecord = aRecord as ResourceRecordA | ResourceRecordAAAA; + + this.update(); + } + } + + resolvePtr() { + const query = new Query( + [ + { + name: this.serviceName, + recordType: ResourceType.SRV, + }, + + { + name: this.serviceName, + recordType: ResourceType.TXT, + }, + ], + this.multicastInterface, + ); + + (async () => { + for await (const event of query) { + switch (event.kind) { + case "ADDED": { + if (isResourceRecordTXT(event.record)) { + this.resolveTxt(event.record); + } else if (isResourceRecordSRV(event.record)) { + this.resolveSrv(event.record); + } + } + } + // TODO: flushed, expired. + } + })(); + } + + resolveTxt(record: ResourceRecordTXT) { + this.txtRecord = record; + + // see if we can resolve a pending promise to be yielded. + this.update(); + } + + async resolveSrv(record: ResourceRecordSRV) { + // make a new query for A / AAAA record for this (use multicast interface family to determine which to ask for) + const recordTypeToRequest = + this.multicastInterface.family === "IPv4" ? ResourceType.A : ResourceType.AAAA; + + this.srvRecord = record; + + const existingQuery = this.hostnameQuery; + + if (existingQuery) { + existingQuery.end(); + } + + const hostnameQuery = new Query( + [ + { + name: record.RDATA.target.join("."), + recordType: recordTypeToRequest, + }, + ], + this.multicastInterface, + ); + + this.hostnameQuery = hostnameQuery; + + for await (const event of hostnameQuery) { + switch (event.kind) { + case "ADDED": { + if (isResourceRecordA(event.record) || isResourceRecordAAAA(event.record)) { + this.hostnameRecord = event.record; + + this.update(); + } + break; + } + case "EXPIRED": + case "FLUSHED": + if (isResourceRecordA(event.record) || isResourceRecordAAAA(event.record)) { + this.hostnameRecord = event.record; + this.update(true); + } + break; + } + } + } + + update(wentInactive?: boolean) { + if (!this.hostnameRecord) { + return; + } + + if (!this.srvRecord) { + return; + } + + if (!this.txtRecord) { + return; + } + + const hostName = isResourceRecordA(this.hostnameRecord) + ? this.hostnameRecord.RDATA.join(".") + : this.hostnameRecord.RDATA; + + const { instanceName, type, subTypes, protocol } = parseServiceName(this.serviceName); + + this.fifo.push({ + name: instanceName, + type: type, + subtypes: subTypes, + protocol: protocol, + host: hostName, + hostname: this.hostnameRecord.NAME.join("."), + port: this.srvRecord.RDATA.port, + txt: this.txtRecord.RDATA, + isActive: !wentInactive, + }); + } + + close() { + this.update(true); + this.fifo.close(); + } + + // Return an async iterator with events of being resolved, updated, going down. + async *[Symbol.asyncIterator]() { + for await (const res of this.fifo) { + yield res; + } + } +} + +function parseServiceName(name: string): { + instanceName: string; + subTypes: string[]; + type: string; + protocol: "udp" | "tcp"; + domain: string; +} { + const parts = name.split("."); + + const domain = parts[parts.length - 1]; + const protocol = parts[parts.length - 2].replace("_", ""); + const type = parts[parts.length - 3].replace("_", ""); + const instanceName = parts[0]; + + const subTypes = []; + + if (parts.length > 4) { + // Then there are sub types. + const subTypesLength = parts.length - 4 - 1; + + for (let i = 0; i < subTypesLength; i++) { + subTypes.push(parts[i + 1].replace("_", "")); + } + } + + return { + instanceName, + subTypes, + type, + protocol: protocol as "udp" | "tcp", + domain, + }; +} diff --git a/app/utils/.server/mdns/fast_fifo.ts b/app/utils/.server/mdns/fast_fifo.ts new file mode 100644 index 00000000..de18525f --- /dev/null +++ b/app/utils/.server/mdns/fast_fifo.ts @@ -0,0 +1,121 @@ +export const END = Symbol("Stream ended."); +export const ERROR = Symbol("Stream errored."); + +export type Enqueueable = T | Promise | typeof END | typeof ERROR; + +export type Resolver = (value: T | PromiseLike) => void; + +export type EndOptions = { immediately?: boolean; withError?: Error }; + +// Adapted from https://github.com/mafintosh/fast-fifo to turn into AsyncIterable + +class FixedFIFO { + buffer: Array; + private mask: number; + private top = 0; + private btm = 0; + + next: null | FixedFIFO = null; + + constructor(hwm: number) { + if (!(hwm > 0) || ((hwm - 1) & hwm) !== 0) { + throw new Error("Max size for a FixedFIFO should be a power of two"); + } + this.buffer = Array.from({ length: hwm }); + this.mask = hwm - 1; + this.top = 0; + this.btm = 0; + } + + push(data: T) { + if (this.buffer[this.top] !== undefined) return false; + this.buffer[this.top] = data; + this.top = (this.top + 1) & this.mask; + return true; + } + + shift() { + const last = this.buffer[this.btm]; + if (last === undefined) return undefined; + this.buffer[this.btm] = undefined; + this.btm = (this.btm + 1) & this.mask; + return last; + } + + peek() { + return this.buffer[this.btm]; + } + + isEmpty() { + return this.buffer[this.btm] === undefined; + } +} + +export class FastFIFO { + private hwm: number; + private head: FixedFIFO>; + private tail: FixedFIFO>; + private resolve: null | Resolver> = null; + + constructor(hwm: number) { + this.hwm = hwm || 16; + this.head = new FixedFIFO(this.hwm); + this.tail = this.head; + } + + push(val: Enqueueable) { + if (this.resolve) { + this.resolve(val); + this.resolve = null; + return; + } + + if (!this.head.push(val)) { + const prev = this.head; + this.head = prev.next = new FixedFIFO>(2 * this.head.buffer.length); + this.head.push(val); + } + } + + shift(): Enqueueable | undefined { + const val = this.tail.shift(); + if (val === undefined && this.tail.next) { + const next = this.tail.next; + this.tail.next = null; + this.tail = next; + return this.tail.shift(); + } + + return val; + } + + peek(): Enqueueable | undefined { + return this.tail.peek(); + } + + isEmpty(): boolean { + return this.head.isEmpty(); + } + + close() { + this.push(END); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + const shifted = this.shift(); + + const value = + shifted || + (await new Promise>((res) => { + this.resolve = res; + })); + + if (value === END || value === ERROR) { + break; + } + + yield value; + } + } +} diff --git a/app/utils/.server/mdns/mdns/constants.ts b/app/utils/.server/mdns/mdns/constants.ts new file mode 100644 index 00000000..1a650ec6 --- /dev/null +++ b/app/utils/.server/mdns/mdns/constants.ts @@ -0,0 +1,6 @@ +/** The IPv4 multicast address. */ +export const MDNS_IPV4 = "224.0.0.251"; +/** The IPv6 multicast address. */ +export const MDNS_IPV6 = "ff02::fb"; +/** The port over which MDNS messages are sent. */ +export const MDNS_PORT = 5353; diff --git a/app/utils/.server/mdns/mdns/driver_node.ts b/app/utils/.server/mdns/mdns/driver_node.ts new file mode 100644 index 00000000..1b25cf2c --- /dev/null +++ b/app/utils/.server/mdns/mdns/driver_node.ts @@ -0,0 +1,87 @@ +import { Buffer } from "node:buffer"; +import { createSocket, type RemoteInfo, Socket } from "node:dgram"; +import { hostname, networkInterfaces } from "node:os"; + +import { FastFIFO } from "../fast_fifo"; +import { MDNS_IPV4, MDNS_IPV6, MDNS_PORT } from "./constants"; +import type { MulticastDriver } from "./multicast_interface"; + +/** A multicast driver using Node's `dgram` module. */ +export class DriverNode implements MulticastDriver { + private socket: Socket; + private messages = new FastFIFO<[Uint8Array, { hostname: string; port: number }]>(16); + + family: "IPv4" | "IPv6"; + address: string; + hostname: string = hostname(); + + constructor(family: "IPv4" | "IPv6") { + this.family = family; + const socket = createSocket(family === "IPv4" ? "udp4" : "udp6"); + + this.socket = socket; + this.address = family === "IPv4" ? "0.0.0.0" : "::"; + + socket.bind(MDNS_PORT, undefined, () => { + socket.addMembership(family === "IPv4" ? MDNS_IPV4 : MDNS_IPV6); + }); + + socket.on("message", (msg: Buffer, rinfo: RemoteInfo) => { + this.messages.push([msg, { hostname: rinfo.address, port: rinfo.port }]); + }); + } + + send(message: Uint8Array): Promise { + return new Promise((res) => { + this.socket.send(message, MDNS_PORT, this.family === "IPv4" ? MDNS_IPV4 : MDNS_IPV6, () => { + res(); + }); + }); + } + + setTTL(ttl: number): Promise { + this.socket.setMulticastTTL(ttl); + + return Promise.resolve(); + } + + setLoopback(loopback: boolean): Promise { + this.socket.setMulticastLoopback(loopback); + + return Promise.resolve(); + } + + receive(): Promise<[Uint8Array, { hostname: string; port: number }]> { + const h = Promise.withResolvers<[Uint8Array, { hostname: string; port: number }]>(); + + (async () => { + for await (const msg of this.messages) { + h.resolve(msg); + break; + } + })(); + + return h.promise; + } + + isOwnAddress(address: string): boolean { + const interfaces = networkInterfaces(); + + for (const key in interfaces) { + const intf = networkInterfaces()[key]; + + // oxlint-disable-next-line typescript/no-for-in-array + for (const addr in intf) { + if (addr === address) { + return true; + } + } + } + + return false; + } + + close(): void { + this.socket.close(); + } +} diff --git a/app/utils/.server/mdns/mdns/multicast_interface.ts b/app/utils/.server/mdns/mdns/multicast_interface.ts new file mode 100644 index 00000000..82a57032 --- /dev/null +++ b/app/utils/.server/mdns/mdns/multicast_interface.ts @@ -0,0 +1,101 @@ +import { DriverNode } from "@thorium/utils/.server/mdns/mdns/driver_node"; + +import { decodeMessage } from "../decode/message_decode"; +import { encodeMessage } from "../decode/message_encode"; +import type { DnsMessage } from "../decode/types"; +import { FastFIFO } from "../fast_fifo"; + +/** A driver which supplies the underlying methods a `MulticastInterface` uses to send and receive multicast messages. */ +export interface MulticastDriver { + family: "IPv4" | "IPv6"; + + hostname: string; + + send(message: Uint8Array): Promise; + + setTTL(ttl: number): Promise; + + receive(): Promise<[Uint8Array, { hostname: string; port: number }]>; + + setLoopback(loopback: boolean): Promise; + + /** Check if a given address belongs to this interface. */ + isOwnAddress(address: string): boolean; + + close(): void; +} + +/** An interface used to send and receive multicast messages, as well as other actions such as toggling multicast loopback. + * + * If no driver is specified, selects a `MulticastDriver` appropriate to the current runtime. + */ +export class MulticastInterface { + private driver: MulticastDriver; + private subscribers: FastFIFO<[DnsMessage, { hostname: string; port: number }]>[] = []; + + constructor(driver: MulticastDriver = new DriverNode("IPv4")) { + this.driver = driver; + const subscribers = this.subscribers; + + (async () => { + while (true) { + const [received, origin] = await driver.receive(); + + try { + const event = [decodeMessage(received), origin] as [ + DnsMessage, + { hostname: string; port: number }, + ]; + + for (const subscriber of subscribers) { + subscriber.push(event); + } + } catch { + console.warn("Could not decode a message from", origin.hostname); + } + } + })(); + } + + /** Send a DNS message. */ + send(message: DnsMessage): Promise { + const encoded = encodeMessage(message); + + return this.driver.send(encoded); + } + + /** Set the time-to-live (TTL). */ + setTTL(ttl: number): void { + if (this.driver.family === "IPv4") { + this.driver.setTTL(ttl); + } + } + + /** Set the loopback (whether messages sent from this machine are received by itself). */ + setLoopback(loopback: boolean): void { + this.driver.setLoopback(loopback); + } + + /** Iterator of received messages. */ + messages(): AsyncIterable<[DnsMessage, { hostname: string; port: number }]> { + const subscriber = new FastFIFO<[DnsMessage, { hostname: string; port: number }]>(16); + + this.subscribers.push(subscriber); + + return subscriber; + } + + /** Return if a host address is our own address. */ + isOwnAddress(address: string): boolean { + return this.driver.isOwnAddress(address); + } + + get hostname(): string { + return this.driver.hostname; + } + + /** Whether this interface uses IPv4 or IPv6 */ + get family(): "IPv4" | "IPv6" { + return this.driver.family; + } +} diff --git a/app/utils/.server/mdns/mdns/query.ts b/app/utils/.server/mdns/mdns/query.ts new file mode 100644 index 00000000..fb9abb88 --- /dev/null +++ b/app/utils/.server/mdns/mdns/query.ts @@ -0,0 +1,459 @@ +import { DnsClass, type DnsMessage, type ResourceRecord, ResourceType } from "../decode/types"; +import { FastFIFO } from "../fast_fifo"; +import { MulticastInterface } from "./multicast_interface"; +import { isConflicting, recordSort } from "./responder"; + +const ONE_SECOND_MS = 1000; + +/** The number of milliseconds in an hour. */ +const ONE_HOUR_MS = ONE_SECOND_MS * 60 * 60; + +export type MdnsQuestion = { + name: string; + recordType: ResourceType; +}; + +/** A continuous multicast DNS query. + * + * Reports additions, flushes, and expirations of resource records answering the given query via an asynchronous iterator: + * + * ```ts + * const query = new Query( + * [{ name: '_http._tcp.local', recordType: 255 }], + * multicastInterface: new MulticastInterface() + * ); + * + * for await (const event of query) { + * } + * ``` + */ +export class Query { + private questions: MdnsQuestion[]; + private minterface: MulticastInterface; + private recordCache = new RecordCache({ + // Teach the cache how to query for expiring records + onRequery: (record: ResourceRecord) => { + this.sendQuery([ + { + name: record.NAME.join("."), + recordType: record.TYPE, + }, + ]); + }, + }); + private scheduled: ReturnType[] = []; + private ended = false; + private suppressedQuestions = new Set(); + private additionalRecords = new SimpleRecordStore(); + + constructor(questions: MdnsQuestion[], multicastInterface: MulticastInterface) { + this.questions = questions; + this.minterface = multicastInterface; + + // Send the initial query. + this.scheduleInitialQuery(); + + // Long running query for some records. + (async () => { + for await (const [message, host] of multicastInterface.messages()) { + if (this.ended) { + break; + } + + if (multicastInterface.isOwnAddress(host.hostname)) { + continue; + } + + if (message.header.QR === 0) { + this.handleQuery(message, host); + } else { + this.handleResponse(message); + } + } + })(); + } + + private scheduleInitialQuery() { + // Delay the initial query by random amount between 20 - 120ms. + const fromNow = Math.random() * (120 - 20) + 20; + + this.scheduleQuery(fromNow, () => { + this.scheduleFurtherQueries(ONE_SECOND_MS); + }); + } + + private scheduleFurtherQueries(inMs: number) { + // Send the query, send again after one second, and then by a factor of two each time. Cap it at 60 mins. + this.scheduleQuery(inMs, () => { + this.scheduleFurtherQueries(Math.min(inMs * 2, ONE_HOUR_MS)); + }); + } + + /** Gets all questions given to this Query which we do not already know the answer to, and which we have not recently seen asked by anyone else via multicast. */ + private getQuestions() { + const validQuestions: MdnsQuestion[] = []; + + for (const question of this.questions) { + // If it's a suppressed question, don't send it. + if (this.suppressedQuestions.has(question)) { + // We only want to suppress it once. + + this.suppressedQuestions.delete(question); + continue; + } + + // If it's a question we already know the answer to, don't send it. + // But ONLY if it's a non-shared record. + if ( + question.recordType !== ResourceType.PTR && + this.recordCache.knownAnswers([question]).length > 0 + ) { + continue; + } + + validQuestions.push(question); + } + + // Clear the suppressed questions. + + return validQuestions; + } + + private scheduleQuery(inMs: number, onTimeout?: () => void) { + const timer = setTimeout(() => { + this.sendQuery(); + + if (onTimeout) { + onTimeout(); + } + }, inMs); + + this.scheduled.push(timer); + } + + private handleQuery(query: DnsMessage, host: { hostname: string; port: number }) { + // It's a query. + + // Is this something we sent ourselves? + if (this.minterface.isOwnAddress(host.hostname)) { + return; + } + + // can only suppress if the known answer section is empty + if (query.answer.length > 0) { + return; + } + + // (7.3) If another + // responder has asked the same question as one this query is about to send, + // this query can suppress that question since someone already asked for it. So we reschedule the question. + for (const question of query.question) { + for (const ourQuestion of this.questions) { + if ( + question.QTYPE === ourQuestion.recordType && + question.QNAME.join(".") === ourQuestion.name + ) { + // SUPPRESS THAT CHATTY CATTY RIGHT NOW... + this.suppressedQuestions.add(ourQuestion); + } + } + } + } + + private askedQuestionFor(record: ResourceRecord) { + for (const question of this.questions) { + const isRightType = + record.TYPE === question.recordType || question.recordType === ResourceType.ANY; + const isRightName = record.NAME.join(".").toUpperCase() === question.name.toUpperCase(); + + if (isRightType && isRightName) { + return true; + } + } + + return false; + } + + private handleResponse(response: DnsMessage) { + // Check if any of the records matches our query... + let additionalAdded = false; + + for (const record of response.answer) { + const answersAnyQuestion = this.askedQuestionFor(record); + + if (!answersAnyQuestion) { + // Not a matching record + continue; + } else if (additionalAdded === false) { + // Add all additional records to the cache if there were any answers in this response. + for (const additionalRecord of response.additional) { + this.additionalRecords.addRecord(additionalRecord); + } + + additionalAdded = true; + } + + // It IS a matching record, so add it to our cache. + + this.recordCache.addRecord(record); + } + } + + /** Sends a DNS query with given questions. + * + * If no questions are provided, uses the ones given to the `Query` at construction. + */ + private async sendQuery(questions?: MdnsQuestion[]) { + const questionsToUse = questions || this.getQuestions(); + + if (questionsToUse.length === 0) { + return; + } + + // Known answer suppression, RFC 6762 section 7.1. + // Include answers we already know + // These should only be shared, non-unique records. + const knownAnswers = this.recordCache.knownAnswers(questionsToUse); + + const message: DnsMessage = { + header: { + ID: 0, + QR: 0, + OPCODE: 0, + AA: 0, + TC: 0, + RD: 0, + RA: 0, + Z: 0, + AD: 0, + CD: 0, + RCODE: 0, + QDCOUNT: questionsToUse.length, + ANCOUNT: knownAnswers.length, + NSCOUNT: 0, + ARCOUNT: 0, + }, + question: questionsToUse.map((q) => ({ + QNAME: q.name.split("."), + QTYPE: q.recordType, + QCLASS: DnsClass.IN, + })), + answer: knownAnswers, + authority: [], + additional: [], + }; + + // Send it! + await this.minterface.send(message); + } + + /** All answers obtained over the life of this query. */ + answers(): ResourceRecord[] { + return this.recordCache.getRecords(); + } + + /** All additional records obtained from responses which had valid answers in them. */ + additional(): ResourceRecord[] { + return this.additionalRecords.getRecords(); + } + + /** Stop this query from running. */ + end() { + this.ended = true; + this.recordCache.close(); + // Clear all the timers. + for (const timer of this.scheduled) { + clearTimeout(timer); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const event of this.recordCache.events) { + yield event; + } + } +} + +export type QueryCacheEvent = { + kind: "ADDED" | "EXPIRED" | "FLUSHED"; + record: ResourceRecord; +}; + +class RecordCache { + private records = new Map[]>(); + private onRequery: (record: ResourceRecord) => void; + + events = new FastFIFO(16); + + constructor(opts: { onRequery: (record: ResourceRecord) => void }) { + this.onRequery = opts.onRequery; + } + + close() { + for (const [record] of this.records) { + this.removeRecord(record); + } + + this.events.close(); + } + + getRecords(): ResourceRecord[] { + return Array.from(this.records.keys()); + } + + addRecord(record: ResourceRecord) { + const expire = () => { + this.expireRecord(record); + }; + + const requery = () => { + this.onRequery(record); + }; + + // Handle goodbye packets with a TTL of 0. + if (record.TTL === 0) { + // Set to expire in 1 second (section 10.1 of RFC 6762) + this.records.set(record, [setTimeout(() => this.expireRecord(record), 1000)]); + + this.events.push({ + kind: "ADDED", + record, + }); + + return; + } + + // If it's unique, flush all records with same name, rrtype, and rrclass. + if (record.isUnique) { + for (const [prevRecord] of this.records) { + if ( + isEqualRecordName(record.NAME, prevRecord.NAME) && + record.TYPE === prevRecord.TYPE && + record.CLASS === prevRecord.CLASS + ) { + if (recordSort(record, prevRecord) === 0) { + // RDATA is the same. Don't flush it. + return; + } + + this.removeRecord(prevRecord); + + this.events.push({ + kind: "FLUSHED", + record: prevRecord, + }); + } + } + } + + // schedule re-query at 80%-82% of record lifetime + // 85%-87% + // 90%-92% + // and 95%-97% + + const timers = [ + setTimeout(expire, record.TTL * 1000), + setTimeout(requery, wigglyPercentOf(80, record.TTL * 1000)), + setTimeout(requery, wigglyPercentOf(85, record.TTL * 1000)), + setTimeout(requery, wigglyPercentOf(90, record.TTL * 1000)), + setTimeout(requery, wigglyPercentOf(95, record.TTL * 1000)), + ]; + + this.records.set(record, timers); + + // Emit an addition + this.events.push({ + kind: "ADDED", + record, + }); + } + + expireRecord(record: ResourceRecord) { + this.removeRecord(record); + + // Requery for it one last time. + this.onRequery(record); + + // Emit expiration + this.events.push({ + kind: "EXPIRED", + record, + }); + } + + removeRecord(record: ResourceRecord) { + const timers = this.records.get(record); + + if (timers) { + for (const timer of timers) { + clearTimeout(timer); + } + } + + this.records.delete(record); + } + + /** Return all records in the cache matching a set of questions. Used for known-answer suppression.*/ + knownAnswers(questions: MdnsQuestion[]) { + const knownAnswers = new Set(); + + for (const [record] of this.records) { + let answersAnyQuestion = false; + + for (const question of questions) { + if ( + record.TYPE === question.recordType && + record.NAME.join(".").toUpperCase() === question.name.toUpperCase() + ) { + answersAnyQuestion = true; + } + } + + if (answersAnyQuestion) { + knownAnswers.add(record); + } + } + + return Array.from(knownAnswers); + } +} + +/** A simple record store which replaces conflicting records. */ +class SimpleRecordStore { + private records = new Set(); + + addRecord(record: ResourceRecord) { + for (const existingRecord of this.records) { + if (isConflicting(record, existingRecord)) { + this.records.delete(existingRecord); + } + } + + this.records.add(record); + } + + getRecords() { + return Array.from(this.records); + } +} + +/** Calculate the given percentage (with +2 wiggle) of a number */ +function wigglyPercentOf(percent: number, total: number) { + const wiggle = Math.random() * (percent + 2 - percent) + percent; + + return (wiggle / 100) * total; +} + +function isEqualRecordName(a: ResourceRecord["NAME"], b: ResourceRecord["NAME"]): boolean { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} diff --git a/app/utils/.server/mdns/mdns/responder.ts b/app/utils/.server/mdns/mdns/responder.ts new file mode 100644 index 00000000..19ee0f87 --- /dev/null +++ b/app/utils/.server/mdns/mdns/responder.ts @@ -0,0 +1,221 @@ +import { concat } from "@thorium/utils/.server/mdns/concat"; + +import { encodeRdataA, encodeRdataAAAA, encodeRdataTXT } from "../decode/message_encode"; +import { + isResourceRecordA, + isResourceRecordAAAA, + isResourceRecordNSEC, + isResourceRecordPTR, + isResourceRecordSRV, + isResourceRecordTXT, + type ResourceRecord, + type ResourceRecordNSEC, + type ResourceRecordSRV, +} from "../decode/types"; +import { MulticastInterface } from "./multicast_interface"; + +export type RespondingRecord = ResourceRecord & { + /** Records which should be included in the additional section of the DNS message when this record is used in a response. */ + additional?: ResourceRecord[]; +}; + +export type RespondOpts = { + /** The DNS records a responder wants to be authoritative for */ + proposedRecords: RespondingRecord[]; + multicastInterface: MulticastInterface; + signal?: AbortSignal; +}; + +/** Compare two records to determine lexicographical order. */ +export function recordSort(a: ResourceRecord, b: ResourceRecord): 1 | 0 | -1 { + if (a.CLASS < b.CLASS) { + return -1; + } else if (a.CLASS > b.CLASS) { + return 1; + } + + if (a.TYPE < b.TYPE) { + return -1; + } else if (a.TYPE > b.TYPE) { + return 1; + } + + // Now we have to compare RDATA. Great. I didn't plan for this, + // so RDATA is only in its decoded form here. We need to quickly re-encode it. + // This might actually be better because the RDATA must be decompressed first. + let rdataA: Uint8Array; + let rdataB: Uint8Array; + + if (isResourceRecordA(a) && isResourceRecordA(b)) { + rdataA = encodeRdataA(a); + rdataB = encodeRdataA(b); + } else if (isResourceRecordPTR(a) && isResourceRecordPTR(b)) { + rdataA = encodeRdataPTR(a.RDATA); + rdataB = encodeRdataPTR(b.RDATA); + } else if (isResourceRecordTXT(a) && isResourceRecordTXT(b)) { + rdataA = encodeRdataTXT(a.RDATA); + rdataB = encodeRdataTXT(b.RDATA); + } else if (isResourceRecordAAAA(a) && isResourceRecordAAAA(b)) { + rdataA = encodeRdataAAAA(a.RDATA); + rdataB = encodeRdataAAAA(b.RDATA); + } else if (isResourceRecordSRV(a) && isResourceRecordSRV(b)) { + rdataA = encodeRdataSRV(a); + rdataB = encodeRdataSRV(b); + } else if (isResourceRecordNSEC(a) && isResourceRecordNSEC(b)) { + rdataA = encodeRdataNSEC(a); + rdataB = encodeRdataNSEC(b); + } else { + rdataA = a.RDATA as Uint8Array; + rdataB = b.RDATA as Uint8Array; + } + + const aView = new DataView(rdataA.buffer); + const bView = new DataView(rdataB.buffer); + + for (let i = 0; i < Math.max(rdataA.byteLength, rdataB.byteLength); i++) { + if (i >= rdataA.byteLength) { + return -1; + } + + const aNum = aView.getUint8(i); + + if (i >= rdataB.byteLength) { + return 1; + } + + const bNum = bView.getUint8(i); + + if (aNum > bNum) { + return 1; + } else if (aNum < bNum) { + return -1; + } + } + + return 0; +} + +/** Checks if two records conflict with each other. + * + * This is when the have the same name and type, but different RDATA. + */ +export function isConflicting(a: ResourceRecord, b: ResourceRecord) { + if (a.isUnique === false || b.isUnique === false) { + return false; + } + + const isSameType = a.TYPE === b.TYPE; + + if (isSameType === false) { + return false; + } + + const isSameName = a.NAME.join(".").toUpperCase() === b.NAME.join(".").toUpperCase(); + + if (isSameName === false) { + return false; + } + + // Records conflict if they have the same name and type but different RDATA. + const order = recordSort(a, b); + + if (order === 0) { + return false; + } + + return true; +} + +// Slightly different encoding methods (which do not support decompression) +// Which are only used to compare RDATA. +// Duplication over over-abstraction, man + +function encodeRdataPTR(labelSeq: string[]): Uint8Array { + return encodeLabelSequence(labelSeq); +} + +function encodeRdataSRV(record: ResourceRecordSRV): Uint8Array { + const srvBytes = new Uint8Array(6); + const srvView = new DataView(srvBytes.buffer); + + srvView.setUint16(0, record.RDATA.priority); + srvView.setUint16(2, record.RDATA.weight); + srvView.setUint16(4, record.RDATA.port); + + const labelBytes = encodeLabelSequence(record.NAME); + + return concat([srvBytes, labelBytes]); +} + +function encodeRdataNSEC(record: ResourceRecordNSEC): Uint8Array { + const labelBytes = encodeLabelSequence(record.NAME); + + const maskLength = record.RDATA.types.length ? Math.ceil(Math.max(...record.RDATA.types) / 8) : 0; + + const masks = Array(maskLength).fill(0); + + for (const type of record.RDATA.types) { + const index = ~~(type / 8); // which mask this rrtype is on + const bit = 7 - (type % 8); // convert to network bit order + + masks[index] |= 1 << bit; + } + + const maskBytes = new Uint8Array(2 + maskLength); + const maskView = new DataView(maskBytes.buffer); + + maskView.setUint8(0, 0); + maskView.setUint8(1, maskLength); + + for (let i = 0; i < masks.length; i++) { + const mask = masks[i]; + + maskView.setUint8(i + 2, mask); + } + + return concat([labelBytes, maskBytes]); +} + +function encodeLabelSequence( + /** The label sequence to be encoded. */ + labelSequence: string[], +): Uint8Array { + if (labelSequence.length === 0) { + // TODO: handle 0-length string + } + + /** How long all of the labels in this sequence will be in bytes */ + const labelsLength = labelSequence.reduce((prev, next) => { + // Add an extra 1 for the length byte + return prev + 1 + next.length; + }, 0); + + /** The length of a pointer in bytes */ + const pointerLength = 0; + /** The length of the terminator, if present */ + const terminatorLength = 1; + + const bytes = new Uint8Array(labelsLength + pointerLength + terminatorLength); + const dataView = new DataView(bytes.buffer); + + let position = 0; + + for (let i = 0; i < labelSequence.length; i++) { + const label = labelSequence[i]; + + // First byte is length + dataView.setUint8(position, label.length); + + position += 1; + + // Followed by label characters + for (let charIdx = 0; charIdx < label.length; charIdx++) { + dataView.setUint8(position, label[charIdx].charCodeAt(0)); + position += 1; + } + } + + dataView.setUint8(position, 0); + + return bytes; +} diff --git a/app/utils/.server/snapshot.ts b/app/utils/.server/snapshot.ts new file mode 100644 index 00000000..90fc2ee1 --- /dev/null +++ b/app/utils/.server/snapshot.ts @@ -0,0 +1,11 @@ +import type { DatabaseContext } from "@thorium/typeguards/isDatabaseContext"; + +export async function snapshot(database: DatabaseContext) { + await database.server.write(true); + await Promise.all( + database.server.plugins.map(async (plugin) => { + await plugin.write(true); + }), + ); + await database.flight?.write(true); +} diff --git a/app/utils/live-query/client/dataChannel.ts b/app/utils/live-query/client/dataChannel.ts index b68344c2..604d59b4 100644 --- a/app/utils/live-query/client/dataChannel.ts +++ b/app/utils/live-query/client/dataChannel.ts @@ -1,13 +1,11 @@ import ReconnectingWebSocket from "reconnecting-websocket"; export async function loadWebSocket() { - const hostname = window.location.hostname; - const protocol = window.location.protocol; + const url = new URL(window.location.href); try { - const port = - process.env.NODE_ENV === "production" ? Number(window.location.port) || 4444 : 3001; - - const socketUrl = `${protocol === "https:" ? "wss" : "ws"}://${hostname}:${port}/ws`; + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = "/ws"; + const socketUrl = url.toString(); const socket = new ReconnectingWebSocket(socketUrl, [], { minReconnectionDelay: 500, diff --git a/bun.lock b/bun.lock index 2dca1c90..c5fb6321 100644 --- a/bun.lock +++ b/bun.lock @@ -12,8 +12,10 @@ "@floating-ui/react": "^0.27.16", "@floating-ui/react-dom": "^2.1.6", "@fontsource-variable/outfit": "^5.2.8", + "@homebridge/ciao": "^1.3.8", "@monaco-editor/react": "^4.8.0-rc.3", "@msgpack/msgpack": "^3.1.3", + "@peculiar/x509": "^2.0.0", "@react-aria/overlays": "^3.32.0", "@react-router/node": "^7.15.1", "@react-router/serve": "^7.15.1", @@ -56,6 +58,7 @@ "react-pdf": "^10.4.1", "react-router": "^7.15.1", "reconnecting-websocket": "^4.4.0", + "reflect-metadata": "^0.2.2", "remix-utils": "^9.3.1", "rng": "^0.2.2", "roman-numerals": "^0.3.2", @@ -289,6 +292,8 @@ "@fontsource-variable/outfit": ["@fontsource-variable/outfit@5.2.8", "", {}, "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA=="], + "@homebridge/ciao": ["@homebridge/ciao@1.3.8", "", { "dependencies": { "debug": "^4.4.3", "fast-deep-equal": "^3.1.3", "source-map-support": "^0.5.21", "tslib": "^2.8.1" }, "bin": { "ciao-bcs": "lib/bonjour-conformance-testing.js" } }, "sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng=="], + "@inquirer/checkbox": ["@inquirer/checkbox@2.5.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" } }, "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA=="], "@inquirer/confirm": ["@inquirer/confirm@3.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw=="], @@ -505,6 +510,30 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw=="], + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "@peculiar/asn1-x509-attr": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.7.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.7.0", "@peculiar/asn1-pkcs8": "^2.7.0", "@peculiar/asn1-rsa": "^2.7.0", "@peculiar/asn1-schema": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.7.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.7.0", "@peculiar/asn1-pfx": "^2.7.0", "@peculiar/asn1-pkcs8": "^2.7.0", "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "@peculiar/asn1-x509-attr": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.7.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.7.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-x509": "^2.7.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@2.0.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], @@ -885,6 +914,8 @@ "arrify": ["arrify@1.0.1", "", {}, "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], @@ -1991,6 +2022,10 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -2053,6 +2088,8 @@ "redeyed": ["redeyed@2.1.1", "", { "dependencies": { "esprima": "~4.0.0" } }, "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], @@ -2349,6 +2386,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], @@ -3101,6 +3140,8 @@ "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], diff --git a/desktop/exe.ts b/desktop/exe.ts index 6b0693a1..daf8f419 100644 --- a/desktop/exe.ts +++ b/desktop/exe.ts @@ -1,3 +1,4 @@ +import "reflect-metadata"; import { startHttpServer } from "../app/bunServer"; startHttpServer({ isKiosk: false, isProd: true }); diff --git a/desktop/index.ts b/desktop/index.ts index f3f703a5..b48b2a7a 100644 --- a/desktop/index.ts +++ b/desktop/index.ts @@ -1,3 +1,4 @@ +import "reflect-metadata"; import { BrowserWindow, BrowserView, Utils } from "electrobun/bun"; import type { ThoriumRPC } from "./rpc"; diff --git a/electrobun.config.ts b/electrobun.config.ts index 1c0cdc7f..68dee719 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -69,8 +69,8 @@ export default { }, mac: { bundleCEF: false, - codesign: false, - notarize: false, + codesign: true, + notarize: true, entitlements: {}, icons: "scripts/Icon.icon", }, diff --git a/mdns-test.ts b/mdns-test.ts new file mode 100644 index 00000000..b2c59097 --- /dev/null +++ b/mdns-test.ts @@ -0,0 +1,53 @@ +import { browse } from "./app/utils/.server/mdns/dns_sd/browse"; +import { MulticastInterface } from "./app/utils/.server/mdns/mdns/multicast_interface"; + +for await (const service of browse({ + multicastInterface: new MulticastInterface(), + service: { type: "http", protocol: "tcp" }, +})) { + console.log("New Service!", service); +} + +// const server = Bun.serve({ +// port: 3000, + +// fetch(request) { +// console.log(new URL(request.url).hostname); +// return new Response( +// ` +// +// Spaceship Game Setup +// +//

One-time setup required

+//

To play over a secure connection, install our local certificate:

+//
    +//
  1. Download the certificate
  2. +//
  3. Open the downloaded file and mark it as Trusted
  4. +//
  5. Play the game!
  6. +//
+//

Platform instructions

+//
Windows +//

Double-click the .crt file → Install Certificate → Local Machine +// → Place in "Trusted Root Certification Authorities"

+//
+//
macOS +//

Double-click the .crt → Keychain Access opens → double-click the cert +// → expand "Trust" → set "When using this certificate" to "Always Trust"

+//
+//
Android +//

Settings → Security → Install from storage → pick the .crt file

+//
+//
iOS +//

Open the .crt link in Safari → Settings → General → VPN & Device Management +// → install the profile → Settings → General → About → Certificate Trust Settings → enable it

+//
+// +// `, +// { headers: { "Content-Type": "text/html" } }, +// ); +// }, +// }); + +// await advertiseMdns(Number(server.url.port)); + +// console.log(server.url.href); diff --git a/package.json b/package.json index bd4024ac..c8bffea4 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,10 @@ "@floating-ui/react": "^0.27.16", "@floating-ui/react-dom": "^2.1.6", "@fontsource-variable/outfit": "^5.2.8", + "@homebridge/ciao": "^1.3.8", "@monaco-editor/react": "^4.8.0-rc.3", "@msgpack/msgpack": "^3.1.3", + "@peculiar/x509": "^2.0.0", "@react-aria/overlays": "^3.32.0", "@react-router/node": "^7.15.1", "@react-router/serve": "^7.15.1", @@ -93,6 +95,7 @@ "react-pdf": "^10.4.1", "react-router": "^7.15.1", "reconnecting-websocket": "^4.4.0", + "reflect-metadata": "^0.2.2", "remix-utils": "^9.3.1", "rng": "^0.2.2", "roman-numerals": "^0.3.2", diff --git a/scripts/devServer.ts b/scripts/devServer.ts index 74ba6b7e..fb46ef9d 100644 --- a/scripts/devServer.ts +++ b/scripts/devServer.ts @@ -1,3 +1,4 @@ +import "reflect-metadata"; import { startHttpServer } from "@thorium/bunServer"; await startHttpServer({ isProd: false, isKiosk: false });