diff --git a/backend/server/api/devices/[id]/profile.reg.get.ts b/backend/server/api/devices/[id]/profile.reg.get.ts new file mode 100644 index 0000000..189b934 --- /dev/null +++ b/backend/server/api/devices/[id]/profile.reg.get.ts @@ -0,0 +1,48 @@ +import { getProtectedDevice } from "../../../db/protectedDevices"; +import { labelToSlug } from "../../../utils/mobileconfig"; +import { generateWindowsDohRegFile } from "../../../utils/regfile"; + +/** + * GET /api/devices/:id/profile.reg + * + * PUBLIC — Windows muss ohne Auth-Header zugreifen können. + * Der dnsToken im Profil IST die Device-Authentifizierung beim DoH-Server. + * + * Liefert ein Windows-Registry-File (.reg) das DoH für den ReBreak-DNS-Server + * in Windows 11 einrichtet. + * + * Content-Type: application/octet-stream + * Encoding: UTF-16 LE mit BOM (required by regedit.exe) + */ +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } }); + + const device = await getProtectedDevice(id); + + if (!device || device.status === "revoked") { + throw createError({ statusCode: 404, data: { error: "DEVICE_NOT_FOUND" } }); + } + + if (device.platform !== "windows") { + throw createError({ + statusCode: 400, + data: { error: "WRONG_PLATFORM", expected: "windows", actual: device.platform }, + }); + } + + const regBuffer = generateWindowsDohRegFile({ + deviceId: device.id, + dnsToken: device.dnsToken, + label: device.label, + }); + + const slug = labelToSlug(device.label); + const filename = `rebreak-${slug || "schutz"}.reg`; + + setHeader(event, "Content-Type", "application/octet-stream"); + setHeader(event, "Content-Disposition", `attachment; filename="${filename}"`); + setHeader(event, "Cache-Control", "no-store"); + + return regBuffer; +}); diff --git a/backend/server/api/devices/enroll.post.ts b/backend/server/api/devices/enroll.post.ts index e51ff3b..6881621 100644 --- a/backend/server/api/devices/enroll.post.ts +++ b/backend/server/api/devices/enroll.post.ts @@ -65,12 +65,16 @@ export default defineEventHandler(async (event) => { const apiBase = (config.public as any)?.apiBase ?? "https://api.rebreak.org"; + // Platform-aware download URL: Windows gets .reg, everything else .mobileconfig + const profileExt = platform === "windows" ? "reg" : "mobileconfig"; + const downloadUrl = `${apiBase}/api/devices/${device.id}/profile.${profileExt}`; + return { success: true, data: { deviceId: device.id, dnsToken: device.dnsToken, - downloadUrl: `${apiBase}/api/devices/${device.id}/profile.mobileconfig`, + downloadUrl, }, }; }); diff --git a/backend/server/utils/regfile.ts b/backend/server/utils/regfile.ts new file mode 100644 index 0000000..2438693 --- /dev/null +++ b/backend/server/utils/regfile.ts @@ -0,0 +1,83 @@ +/** + * Windows Registry file generation for DoH (DNS-over-HTTPS) protection setup. + * + * Windows 11 native DoH client configuration via registry: + * - HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DohWellKnownServers + * - Subkey per DoH server with DohTemplate + AutoUpgradeFlag + * + * Registry file encoding: UTF-16 LE with BOM (\xFF\xFE). + * Line endings: CRLF (\r\n) — required by regedit.exe. + * + * Escape rules for .reg string values: + * \ → \\ + * " → \" + * (newlines, tabs in string values would also need escaping but label is + * limited to printable user input, so \n/\t coverage is defensive.) + */ + +/** Escape a string for use inside a .reg double-quoted value. */ +function regEscape(str: string): string { + return str + .replace(/\\/g, "\\\\") // must be first + .replace(/"/g, '\\"') + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t"); +} + +export interface WindowsDohRegOpts { + /** ProtectedDevice.id — not used in file body but available for future use. */ + deviceId: string; + /** 32-char hex DNS token — used in DoH URL. */ + dnsToken: string; + /** User-set device label, e.g. "Büro-PC". */ + label: string; +} + +/** + * Generates the text content of a Windows .reg file that registers a + * ReBreak DoH server in the Windows 11 DoH well-known-servers list. + * + * Returns a UTF-16 LE Buffer with BOM — ready to write as .reg file or send + * as HTTP response body. Windows regedit.exe requires this encoding. + */ +export function generateWindowsDohRegFile( + opts: WindowsDohRegOpts, +): Buffer { + const { dnsToken, label } = opts; + + const tokenPrefix = dnsToken.slice(0, 8); + const subkeyName = `rebreak-${tokenPrefix}`; + const escapedLabel = regEscape(label); + + // RFC 8484 URI template — {?dns} is the query parameter for GET requests. + // Windows DoH client replaces {?dns} with ?dns=. + const dohTemplate = `https://dns.rebreak.org/api/dns/${dnsToken}/dns-query{?dns}`; + + const isoDate = new Date().toISOString().slice(0, 10); + + const CRLF = "\r\n"; + + const lines = [ + "Windows Registry Editor Version 5.00", + "", + `; ReBreak DNS-over-HTTPS Filter — Device: ${escapedLabel}`, + `; Token: ${tokenPrefix}`, + `; Generated: ${isoDate}`, + "", + "[HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DohWellKnownServers]", + "", + `[HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DohWellKnownServers\\${subkeyName}]`, + `"DohTemplate"="${regEscape(dohTemplate)}"`, + '"AutoUpgradeFlag"=dword:00000001', + "", + ]; + + const text = lines.join(CRLF); + + // UTF-16 LE BOM: 0xFF 0xFE + const bom = Buffer.from([0xff, 0xfe]); + const body = Buffer.from(text, "utf16le"); + + return Buffer.concat([bom, body]); +} diff --git a/backend/tests/devices/regfile.test.ts b/backend/tests/devices/regfile.test.ts new file mode 100644 index 0000000..9019aeb --- /dev/null +++ b/backend/tests/devices/regfile.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for generateWindowsDohRegFile + labelToSlug. + * + * Pure utility — no Prisma, no Nitro globals needed. + */ +import { describe, expect, it } from "vitest"; +import { generateWindowsDohRegFile } from "../../server/utils/regfile"; +import { labelToSlug } from "../../server/utils/mobileconfig"; + +const SAMPLE_TOKEN = "abcdef0123456789abcdef0123456789"; // 32 hex chars +const SAMPLE_DEVICE_ID = "device-uuid-1234"; + +describe("generateWindowsDohRegFile", () => { + it("output starts with UTF-16 LE BOM (0xFF 0xFE)", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + expect(buf[0]).toBe(0xff); + expect(buf[1]).toBe(0xfe); + }); + + it("decoded content begins with registry header", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + // Skip 2-byte BOM, decode remainder as UTF-16 LE + const text = buf.slice(2).toString("utf16le"); + expect(text.startsWith("Windows Registry Editor Version 5.00")).toBe(true); + }); + + it("contains the full dnsToken in the DohTemplate value", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + const text = buf.slice(2).toString("utf16le"); + expect(text).toContain(SAMPLE_TOKEN); + }); + + it("uses token prefix (first 8 chars) in the subkey name and comment", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + const text = buf.slice(2).toString("utf16le"); + const prefix = SAMPLE_TOKEN.slice(0, 8); // "abcdef01" + expect(text).toContain(`rebreak-${prefix}`); + expect(text).toContain(`Token: ${prefix}`); + }); + + it("contains AutoUpgradeFlag as dword:00000001", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + const text = buf.slice(2).toString("utf16le"); + expect(text).toContain('"AutoUpgradeFlag"=dword:00000001'); + }); + + it("uses CRLF line endings throughout", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + const text = buf.slice(2).toString("utf16le"); + // Every newline must be preceded by \r + const lines = text.split("\r\n"); + // Rejoining with \r\n and comparing proves all endings were CRLF + expect(lines.join("\r\n")).toBe(text); + // And there must be multiple lines + expect(lines.length).toBeGreaterThan(5); + }); + + describe("label escaping", () => { + it('escapes double-quote in label', () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: 'My "Gaming" PC', + }); + const text = buf.slice(2).toString("utf16le"); + // In comment line the label appears escaped + expect(text).toContain('My \\"Gaming\\" PC'); + }); + + it("escapes backslash in label", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "C:\\Users\\PC", + }); + const text = buf.slice(2).toString("utf16le"); + expect(text).toContain("C:\\\\Users\\\\PC"); + }); + + it("escapes newline in label", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Line1\nLine2", + }); + const text = buf.slice(2).toString("utf16le"); + expect(text).toContain("Line1\\nLine2"); + }); + }); + + it("contains the correct HKLM registry path for DohWellKnownServers", () => { + const buf = generateWindowsDohRegFile({ + deviceId: SAMPLE_DEVICE_ID, + dnsToken: SAMPLE_TOKEN, + label: "Test PC", + }); + const text = buf.slice(2).toString("utf16le"); + expect(text).toContain( + "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DohWellKnownServers", + ); + }); +}); + +describe("labelToSlug", () => { + it("lowercases and replaces non-alphanumeric with hyphens", () => { + expect(labelToSlug("Mein PC")).toBe("mein-pc"); + expect(labelToSlug("Büro-PC 2024")).toBe("b-ro-pc-2024"); + }); + + it("strips leading and trailing hyphens", () => { + expect(labelToSlug(" --test-- ")).toBe("test"); + }); + + it("truncates to 40 characters", () => { + const long = "a".repeat(50); + expect(labelToSlug(long)).toHaveLength(40); + }); + + it("returns empty string for label with no alphanumeric chars", () => { + expect(labelToSlug("---")).toBe(""); + }); +});