diff --git a/docs/guides/init-project.md b/docs/guides/init-project.md index 6e28cdc6e..6cb9aedf7 100644 --- a/docs/guides/init-project.md +++ b/docs/guides/init-project.md @@ -18,10 +18,14 @@ Notes: entire suffix, so do not select a domain used by unrelated sites. The global installer configures the Docker-backed host stack. An explicitly selected native candidate refuses it to preserve the installed v4 stack. - On macOS, use `hack global dns preview` and then the separately confirmed - `hack global dns activate` for a dedicated custom native suffix. This leaves - the v4 wildcard rules untouched. Verify the exact app origin with - `hack doctor --browser-url`; DNS setup alone does not validate HTTPS or OAuth. + On macOS, use `hack global dns preview --domain ` and then the + separately confirmed `hack global dns activate --domain ` for a + dedicated custom native suffix. This leaves the v4 wildcard rules untouched. + To point the default `*.hack.local` root at the native relay, run + `hack doctor --fix`; after confirmation it rewrites only the Hack-managed + `.hack.local` dnsmasq line, and `hack global dns deactivate --domain hack.local` + restores it. Verify the exact app origin with `hack doctor --browser-url`; + DNS setup alone does not validate HTTPS or OAuth. Pass `--dev-host` for a per-project hostname; it takes precedence over the global default. Existing project hosts and Compose routes are not migrated. - On macOS, Safari opens `.hack.local` names directly. Chrome, Firefox and other diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 9b1d076ce..9e0b727e7 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1274,9 +1274,11 @@ browser session works. The native candidate refuses `hack global install`: that command owns the Docker global stack and could change installed v4 routing. For a dedicated custom suffix alongside v4, run `hack global dns preview --domain v5.hack.gy` -and then `hack global dns activate --domain v5.hack.gy`. Omit `--domain` to use -the configured global `default_domain`; changing that shared setting also -changes the installed v4 CLI's default. Preview is read-only; +and then `hack global dns activate --domain v5.hack.gy`. Without `--domain`, +these commands use the global `default_domain` only when it is a custom suffix; +changing that shared setting also changes the installed v4 CLI's default. With a +built-in default such as `hack.local`, they stop with a usage message that names +`hack doctor --fix` and the rollback command below. Preview is read-only; activation shows the exact scoped claim and requires interactive confirmation and a native administrator prompt. Run it from a terminal; the bounded privileged steps preserve that terminal's sudo authorization and report the underlying @@ -1316,10 +1318,81 @@ administrator prompt. It checks the owner, root and route again after that prompt and verifies trust after installation. A missing, stale or changed owner refuses repair. The native `hack global trust` path directs you to this scoped Doctor command instead of falling back to Docker's exported CA. The native -repair does not set up DNS or a browser's Local Network permission. +trust repair does not set up DNS or a browser's Local Network permission. Both authorization and installation retain the caller's controlling terminal, so terminal-scoped sudo authorization remains valid during the bounded repair. +`hack.local` stays the default dev-host root. An older Docker-era install routes +it to Docker Caddy with `address=/.hack.local/172.30.0.2` in Homebrew's +`dnsmasq.conf`, which the native loopback relay cannot serve. On macOS, native +`hack doctor` reports a `native hack.local route` check (JSON field +`hackLocalRoute`), and `hack doctor --fix` proposes a separate DNS repair. It +rewrites only that one Hack-managed line to `address=/.hack.local/127.0.0.1`, +in place. It never adds a second line for the suffix, and the `.hack` and +`.hack.gy` lines and every other byte stay unchanged. If +`/etc/resolver/hack.local` is missing, the repair creates it with +`nameserver 127.0.0.1`. The repair proceeds only when all of these hold: + +- The native relay owner is verified and listening on 127.0.0.1 at its receipted port. +- `dnsmasq.conf`, every `dnsmasq.d/*.conf` include file and the resolver file + are bounded regular UTF-8 files with one link and are not symlinks. +- Neither `dnsmasq.conf` nor an include file loads a DNS source Hack did not + inspect: `conf-file`, `conf-script`, `addn-hosts`, `hostsdir`, `servers-file`, + or a `conf-dir` other than `/etc/dnsmasq.d,*.conf`. +- The line has the exact Hack-managed form and is the only `.hack.local` claim, + including `dnsmasq.d` include files. +- The target is one Hack wrote (`172.30.0.2` or `::1`). +- The old target does not answer on port 443 within 1.5 seconds, so a running + Docker Caddy is never broken. +- Any existing resolver file contains only `nameserver 127.0.0.1`. + +Each unmet condition is a closed refusal reason: `relay-not-verified`, +`dnsmasq-config-missing`, `unreadable-config`, `uninspected-config-source`, +`no-managed-line`, `unmanaged-line`, `foreign-target`, `foreign-resolver`, +`old-target-live`, `pending-receipt`, `invalid-receipt`, `non-interactive`, +`declined`, `busy`, `restart-unavailable` or `inputs-changed`. Refusal details +name files and lines by number and never repeat file contents. The repair needs interactive confirmation and the native macOS +administrator prompt; without a terminal it refuses and names `hack doctor --fix` +to run in one. Only one repair or rollback runs at a time; a second one refuses +with `busy`. After the prompt the repair checks that the dnsmasq launchd job can +be restarted (`restart-unavailable` otherwise), then inspects everything again +and refuses if anything changed. Before changing files it writes a private +receipt to `/native-dns/hack-local-route.json` with the old line, +new line, SHA-256 hashes of the exact file bytes and timestamps. It replaces the +file atomically and keeps every other byte, including a leading byte-order mark. +It then restarts the verified dnsmasq launchd job, flushes the DNS cache and +verifies live resolution. If a later step fails, it restores the earlier line +automatically; when the failure came before any change, it reports that nothing +changed. `hack global dns preview --domain hack.local` shows the same proposal +without changing anything. + +The repair routes names to 127.0.0.1 but does not change ports. With a +`HACK_NATIVE_HTTPS_PORT` other than 443, such as 18443, URLs still need that +port, for example `https://app.hack.local:18443`. The confirmation prompt names +the relay port. + +To undo the repair, run `hack global dns deactivate --domain hack.local`. It +needs a terminal and refuses with `non-interactive` and the same command +otherwise. It refuses with `unreadable-config` and changes nothing when +`dnsmasq.conf` is not a bounded regular UTF-8 file. It restores the exact earlier line and verifies the whole-file hash +against the receipt. It removes the resolver file only if the repair created it +and its identity still matches, then restarts dnsmasq. If dnsmasq is not +running, or the job cannot be restarted safely, the rollback still restores the +file and says that dnsmasq was not restarted; the line loads on its next start. + +An interrupted repair leaves a `pending` receipt, and Doctor refuses another +repair until the same rollback command handles it. Sometimes `dnsmasq.conf` +changed after the repair, for example when a Docker-era `hack doctor --fix` or +`hack global install` rewrote the built-in roots. In that case, or when the +receipt names another `dnsmasq.conf` path, restoring would overwrite those +changes. The rollback command then never writes the file. Instead, after a +plain confirmation, it marks the receipt `released` and leaves `dnsmasq.conf` +and any resolver file as they are. Doctor then inspects the current file again. +Restore the recorded line yourself only if you still want the earlier route. A +Docker-era `hack doctor --fix` or `hack global install` still rewrites all +three built-in roots to whichever target answers, which can undo the native +repair. + The runtime's read-only `runtime inspect-host-listener --pid PID --port PORT --executable /absolute/caddy --json` reports the selected same-user loopback listener identity. The optional `--peer-port PEER_PORT` proves Caddy accepted diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c01102a90..dd1f96c8c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1022,9 +1022,9 @@ hack global dns [options] | Command | Summary | | --- | --- | -| `hack global dns preview` | Preview a scoped native custom-domain DNS claim without changing the host | +| `hack global dns preview` | Preview a scoped native custom-domain DNS claim or the hack.local relay repair without changing the host | | `hack global dns activate` | Activate only the selected native custom-domain DNS suffix | -| `hack global dns deactivate` | Remove only an owned native custom-domain DNS suffix | +| `hack global dns deactivate` | Remove an owned native custom-domain DNS suffix, or undo the hack.local relay repair | ### Options diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 7bf30d600..12812853f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -116,6 +116,17 @@ import { inspectNativeCaTrust, repairNativeCaTrust, } from "../lib/native-ca-doctor.ts"; +import { + describeHackLocalRoute, + HACK_LOCAL_ROLLBACK_COMMAND, + type HackLocalRouteDependencies, + type HackLocalRouteReport, + inspectHackLocalRoute, + printable, + repairHackLocalRoute, + reportHackLocalRoute, +} from "../lib/native-hack-local-route.ts"; +import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local-route-host.ts"; import { isMac } from "../lib/os.ts"; import { defaultProjectSlugFromPath, @@ -178,10 +189,15 @@ interface CheckResult { readonly message: string; /** Counts and public fingerprints only; never paths or keychain contents. */ readonly supersededRoots?: SupersededRootInventory; + /** Native hack.local route state; lines and paths only, never file contents. */ + readonly hackLocalRoute?: HackLocalRouteReport; /** Closed, value-free env key findings; project-relative subjects only. */ readonly envKey?: EnvKeyInspection; } +const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; +const HACK_LOCAL_REPAIR_CHECK = "native hack.local repair"; + const SUPERSEDED_ROOTS_CHECK = "superseded caddy roots"; /** Informational inventory: Doctor and --fix never remove a keychain root. */ @@ -354,6 +370,8 @@ const DOCTOR_SUMMARY_GROUPS = [ `dnsmasq.conf:${DEFAULT_NEW_PROJECT_TLD}`, `dnsmasq.conf:${DEFAULT_OAUTH_ALIAS_ROOT}`, "dnsmasq:53", + HACK_LOCAL_REPAIR_CHECK, + HACK_LOCAL_ROUTE_CHECK, "native host dns", `dns:${DEFAULT_PROJECT_TLD}`, `dns:${DEFAULT_NEW_PROJECT_TLD}`, @@ -1115,6 +1133,16 @@ async function handleNativeDoctor(opts: { durationMs: 0, }); } + if (isMac()) { + results.push( + ...(await checkNativeHackLocalRoute({ + progress, + fix: opts.fix, + dependencies: () => + createNativeHackLocalRouteDependencies({ runtime: opts.runtime }), + })) + ); + } if (hostname) { results.push( await runCheck( @@ -1160,6 +1188,81 @@ async function handleNativeDoctor(opts: { return hasError ? 1 : 0; } +/** + * Report, and with --fix authorize and apply, the native hack.local route + * repair. Refusals are warnings with a closed reason; only an unexpected + * failure is an error. + */ +export async function checkNativeHackLocalRoute(opts: { + readonly progress: DoctorProgress; + readonly fix: boolean; + readonly dependencies: () => Promise; +}): Promise { + const results: TimedCheckResult[] = []; + let deps: HackLocalRouteDependencies; + try { + deps = await opts.dependencies(); + } catch (error) { + return [ + { + name: HACK_LOCAL_ROUTE_CHECK, + status: "warn", + message: printable( + `Unable to inspect the hack.local route: ${error instanceof Error ? error.message : String(error)}` + ), + durationMs: 0, + }, + ]; + } + if (opts.fix) { + results.push( + await runCheck(opts.progress, HACK_LOCAL_REPAIR_CHECK, async () => { + const repaired = await repairHackLocalRoute(deps); + if (repaired.outcome === "repaired") { + return { + name: HACK_LOCAL_REPAIR_CHECK, + status: "ok", + message: printable( + `Rewrote "${repaired.receipt.oldLine}" to "${repaired.receipt.newLine}"${ + repaired.receipt.resolverAction === "create" + ? ` and created ${repaired.receipt.resolverPath}` + : "" + }; live DNS verified. Receipt: ${deps.paths.receiptPath}. Undo with: ${HACK_LOCAL_ROLLBACK_COMMAND}` + ), + }; + } + if (repaired.outcome === "current") { + return { + name: HACK_LOCAL_REPAIR_CHECK, + status: "ok", + message: + "No repair needed; *.hack.local already routes to the native relay.", + }; + } + return { + name: HACK_LOCAL_REPAIR_CHECK, + status: "warn", + message: printable( + `Repair refused (${repaired.reason}): ${repaired.detail}` + ), + }; + }) + ); + } + results.push( + await runCheck(opts.progress, HACK_LOCAL_ROUTE_CHECK, async () => { + const report = reportHackLocalRoute(await inspectHackLocalRoute(deps)); + return { + name: HACK_LOCAL_ROUTE_CHECK, + status: report.status === "current" ? "ok" : "warn", + message: describeHackLocalRoute(report), + hackLocalRoute: report, + }; + }) + ); + return results; +} + export type DoctorJsonCheck = { readonly id: string; readonly status: CheckStatus; @@ -1167,6 +1270,8 @@ export type DoctorJsonCheck = { readonly durationMs: number; /** Present only on the superseded Caddy roots check. */ readonly supersededRoots?: SupersededRootInventory; + /** Present only on the native hack.local route check. */ + readonly hackLocalRoute?: HackLocalRouteReport; /** Present only on the env key check: closed codes, no key bytes or absolute paths. */ readonly envKey?: EnvKeyInspection; }; @@ -1200,6 +1305,9 @@ export function buildDoctorJsonData(opts: { ...(result.supersededRoots ? { supersededRoots: result.supersededRoots } : {}), + ...(result.hackLocalRoute + ? { hackLocalRoute: result.hackLocalRoute } + : {}), ...(result.envKey ? { envKey: result.envKey } : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), diff --git a/src/commands/global.ts b/src/commands/global.ts index 6089122a6..d7e249d8e 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -112,6 +112,15 @@ import { inspectNativeDnsHost, readStableNativeDnsFile, } from "../lib/native-domain-dns-snapshot.ts"; +import { + describeHackLocalRollback, + describeHackLocalRoute, + inspectHackLocalRoute, + reportHackLocalRoute, + rollbackHackLocalRoute, + selectNativeDnsCommandDomain, +} from "../lib/native-hack-local-route.ts"; +import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local-route-host.ts"; import { isLinux, isMac } from "../lib/os.ts"; import { reconcileRemoteCaddyRoutesStack, @@ -222,7 +231,7 @@ const globalInstallSpec = defineCommand({ const globalDnsPreviewSpec = defineCommand({ name: "preview", summary: - "Preview a scoped native custom-domain DNS claim without changing the host", + "Preview a scoped native custom-domain DNS claim or the hack.local relay repair without changing the host", group: "Global", options: [ optJson, @@ -231,7 +240,8 @@ const globalDnsPreviewSpec = defineCommand({ type: "string", long: "--domain", valueHint: "v5.hack.gy", - description: "Custom suffix to inspect (default: global default_domain)", + description: + "Suffix to inspect: a custom suffix, or hack.local for the native relay repair (default: a custom global default_domain)", } as const), ] as const, positionals: [], @@ -248,7 +258,8 @@ const globalDnsActivateSpec = defineCommand({ type: "string", long: "--domain", valueHint: "v5.hack.gy", - description: "Custom suffix to activate (default: global default_domain)", + description: + "Custom suffix to activate (default: a custom global default_domain)", } as const), ] as const, positionals: [], @@ -257,7 +268,8 @@ const globalDnsActivateSpec = defineCommand({ const globalDnsDeactivateSpec = defineCommand({ name: "deactivate", - summary: "Remove only an owned native custom-domain DNS suffix", + summary: + "Remove an owned native custom-domain DNS suffix, or undo the hack.local relay repair", group: "Global", options: [ defineOption({ @@ -266,7 +278,7 @@ const globalDnsDeactivateSpec = defineCommand({ long: "--domain", valueHint: "v5.hack.gy", description: - "Custom suffix to deactivate (default: global default_domain)", + "Custom suffix to deactivate, or hack.local to undo the native relay repair (default: a custom global default_domain)", } as const), ] as const, positionals: [], @@ -391,11 +403,28 @@ export const globalCommand = defineCommand({ ], } as const); +async function selectGlobalDnsDomain(opts: { + readonly verb: "preview" | "activate" | "deactivate"; + readonly option: string | undefined; +}) { + const explicit = + opts.option === undefined ? undefined : parseDefaultDomain(opts.option); + return selectNativeDnsCommandDomain({ + verb: opts.verb, + explicit, + configuredDefault: explicit ?? (await resolveDefaultDomain()), + }); +} + async function handleGlobalDnsPreview({ args, }: Parameters< CommandHandlerFor >[0]): Promise { + const selected = await selectGlobalDnsDomain({ + verb: "preview", + option: args.options.domain, + }); const runtime = resolveNativeRuntimeSelection(); if (!(runtime && isMac())) { logger.error({ @@ -404,9 +433,20 @@ async function handleGlobalDnsPreview({ }); return 1; } - const domain = parseDefaultDomain( - args.options.domain ?? (await resolveDefaultDomain()) - ); + if (selected.kind === "hack-local") { + const report = reportHackLocalRoute( + await inspectHackLocalRoute( + await createNativeHackLocalRouteDependencies({ runtime }) + ) + ); + if (args.options.json) { + console.log(JSON.stringify({ ok: true, data: report })); + } else { + logger.info({ message: describeHackLocalRoute(report) }); + } + return 0; + } + const { domain } = selected; const { inspection, receiptPath } = await inspectNativeDnsPlan({ runtime, domain, @@ -442,6 +482,10 @@ async function handleGlobalDnsActivate({ }: Parameters< CommandHandlerFor >[0]): Promise { + const selected = await selectGlobalDnsDomain({ + verb: "activate", + option: args.options.domain, + }); const runtime = resolveNativeRuntimeSelection(); if (!(runtime && isMac())) { logger.error({ @@ -450,9 +494,13 @@ async function handleGlobalDnsActivate({ }); return 1; } - const domain = parseDefaultDomain( - args.options.domain ?? (await resolveDefaultDomain()) - ); + if (selected.kind !== "custom") { + logger.error({ + message: "hack.local is repaired with hack doctor --fix, not activated.", + }); + return 1; + } + const { domain } = selected; const initial = await inspectNativeDnsPlan({ runtime, domain }); if ( initial.inspection.plan.status === "available" && @@ -537,6 +585,10 @@ async function handleGlobalDnsDeactivate({ }: Parameters< CommandHandlerFor >[0]): Promise { + const selected = await selectGlobalDnsDomain({ + verb: "deactivate", + option: args.options.domain, + }); const runtime = resolveNativeRuntimeSelection(); if (!(runtime && isMac())) { logger.error({ @@ -545,9 +597,19 @@ async function handleGlobalDnsDeactivate({ }); return 1; } - const domain = parseDefaultDomain( - args.options.domain ?? (await resolveDefaultDomain()) - ); + if (selected.kind === "hack-local") { + const result = await rollbackHackLocalRoute( + await createNativeHackLocalRouteDependencies({ runtime }) + ); + const message = describeHackLocalRollback(result); + if (result.outcome === "refused") { + logger.error({ message }); + return 1; + } + logger.success({ message }); + return 0; + } + const { domain } = selected; const initial = await inspectNativeDnsPlan({ runtime, domain }); if (initial.inspection.plan.status === "available") { if (initial.receipt?.state === "inactive") { diff --git a/src/lib/native-domain-dns-host.ts b/src/lib/native-domain-dns-host.ts index 378ee367c..b442a81a0 100644 --- a/src/lib/native-domain-dns-host.ts +++ b/src/lib/native-domain-dns-host.ts @@ -441,6 +441,36 @@ function assertReceiptState(opts: { } } +/** + * Run one bounded resolver create/remove through the caller's existing sudo + * ticket. The script accepts only direct children of /etc/resolver, creates + * without clobbering and removes only an exact identity and content match. + * Create returns the new file's "dev:ino" identity; remove returns "1" or "0". + */ +export async function runPrivilegedResolverOperation(opts: { + readonly action: "create" | "remove"; + readonly file: + | NativeDnsOwnedFile + | { readonly path: string; readonly content: string }; + readonly runPrivileged?: NativeDnsCommandRunner; +}): Promise { + const runPrivileged = opts.runPrivileged ?? defaultPrivilegedRunner; + const result = await runPrivileged([ + "sudo", + "-n", + "/usr/bin/python3", + "-I", + "-S", + "-c", + PRIVILEGED_RESOLVER_SCRIPT, + opts.action, + opts.file.path, + opts.file.content, + "identity" in opts.file ? opts.file.identity : "", + ]); + return assertSuccess(result, `Resolver ${opts.action}`); +} + /** * Build the effect boundary for a previously validated native DNS plan. The * caller owns authorization, fresh inspection, service restart and cache flush. @@ -458,20 +488,11 @@ export function createNativeDnsHostDependencies( | NativeDnsOwnedFile | { readonly path: string; readonly content: string } ): Promise { - const result = await runPrivileged([ - "sudo", - "-n", - "/usr/bin/python3", - "-I", - "-S", - "-c", - PRIVILEGED_RESOLVER_SCRIPT, + return await runPrivilegedResolverOperation({ action, - file.path, - file.content, - "identity" in file ? file.identity : "", - ]); - return assertSuccess(result, `Resolver ${action}`); + file, + runPrivileged, + }); } return { diff --git a/src/lib/native-domain-dns-plan.ts b/src/lib/native-domain-dns-plan.ts index 3db702654..c31ff68a1 100644 --- a/src/lib/native-domain-dns-plan.ts +++ b/src/lib/native-domain-dns-plan.ts @@ -204,17 +204,40 @@ function checkRuntimeConfigSource(opts: { } } -function checkConfigSources(opts: NativeDnsPlanOptions): void { +/** A config line that makes dnsmasq load DNS data Hack did not inspect. */ +export interface UninspectedDnsmasqSource { + /** One-based line number in the scanned content. */ + readonly line: number; + /** Lowercased directive name; always `conf-dir` or a SOURCE_DIRECTIVES entry. */ + readonly directive: string; +} + +/** + * Directives that read DNS data from another file, directory or program. + * `conf-dir` is handled separately because the inspected include directory + * itself is allowed. + */ +const SOURCE_DIRECTIVES: ReadonlySet = new Set([ + "conf-file", + "conf-script", + "addn-hosts", + "hostsdir", + "servers-file", +]); + +/** + * Pure scan of one dnsmasq configuration for sources Hack never inspected: + * `conf-file`, `conf-script`, `addn-hosts`, `hostsdir`, `servers-file`, and + * any `conf-dir` other than exactly `,*.conf`. Returns the first + * such line, or null. The result never carries the directive's value, so a + * caller can report it without echoing file contents. + */ +export function findUninspectedDnsmasqSource(opts: { + readonly content: string; + readonly includeDir: string; +}): UninspectedDnsmasqSource | null { const included = `${opts.includeDir},*.conf`; - for (const [index, arg] of opts.dnsmasqArgs.entries()) { - checkRuntimeConfigSource({ - arg, - next: opts.dnsmasqArgs[index + 1], - included, - mainPath: opts.mainConfig.path, - }); - } - for (const rawLine of (opts.mainConfig.content ?? "").split(LINE_BREAK)) { + for (const [index, rawLine] of opts.content.split(LINE_BREAK).entries()) { const line = rawLine.trim(); if (line.startsWith("#")) { continue; @@ -227,13 +250,32 @@ function checkConfigSources(opts: NativeDnsPlanOptions): void { const value = equal < 0 ? "" : line.slice(equal + 1).trim(); if ( (directive === "conf-dir" && value !== included) || - directive === "conf-file" || - directive === "addn-hosts" || - directive === "hostsdir" + SOURCE_DIRECTIVES.has(directive) ) { - refuse("the main configuration loads uninspected DNS files"); + return { line: index + 1, directive }; } } + return null; +} + +function checkConfigSources(opts: NativeDnsPlanOptions): void { + const included = `${opts.includeDir},*.conf`; + for (const [index, arg] of opts.dnsmasqArgs.entries()) { + checkRuntimeConfigSource({ + arg, + next: opts.dnsmasqArgs[index + 1], + included, + mainPath: opts.mainConfig.path, + }); + } + if ( + findUninspectedDnsmasqSource({ + content: opts.mainConfig.content ?? "", + includeDir: opts.includeDir, + }) + ) { + refuse("the main configuration loads uninspected DNS files"); + } } function checkParentClaim(opts: NativeDnsPlanOptions): string { diff --git a/src/lib/native-domain-dns-snapshot.ts b/src/lib/native-domain-dns-snapshot.ts index 2679378bf..bddaadc36 100644 --- a/src/lib/native-domain-dns-snapshot.ts +++ b/src/lib/native-domain-dns-snapshot.ts @@ -31,7 +31,10 @@ function missing(error: unknown): boolean { ); } -/** Read a bounded regular file through a non-following descriptor. */ +/** + * Read a bounded regular file through a non-following descriptor. The result + * is a lossless decoding of strictly valid UTF-8, including any BOM. + */ export async function readStableNativeDnsFile( path: string ): Promise { @@ -68,7 +71,11 @@ export async function readStableNativeDnsFile( ) { throw new Error("DNS configuration changed during inspection"); } - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + // Keep a leading BOM: the string must re-encode to exactly these bytes, + // so a rewrite preserves them and hashes of it equal the raw file hash. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes + ); } finally { await file.close(); } diff --git a/src/lib/native-hack-local-route-host.ts b/src/lib/native-hack-local-route-host.ts new file mode 100644 index 000000000..53e61c524 --- /dev/null +++ b/src/lib/native-hack-local-route-host.ts @@ -0,0 +1,511 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { + link, + lstat, + mkdir, + open, + readdir, + rename, + unlink, +} from "node:fs/promises"; +import { createConnection } from "node:net"; +import { basename, dirname, join, resolve } from "node:path"; +import { inspectActiveNativeHttpsOwner } from "../backends/native-project-https.ts"; +import type { NativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import { DEFAULT_HOST_DNS_IP } from "../constants.ts"; +import { canPrompt, confirmSafe } from "./interactivity.ts"; +import { + type NativeDnsCommandRunner, + runNativeDnsAttachedCommand, + runPrivilegedResolverOperation, +} from "./native-domain-dns-host.ts"; +import { prepareNativeDnsLaunchdRestart } from "./native-domain-dns-launchd.ts"; +import { + inspectNativeDnsHost, + readStableNativeDnsFile, +} from "./native-domain-dns-snapshot.ts"; +import { + HACK_LOCAL_DOMAIN, + type HackLocalRouteDependencies, + type HackLocalRouteReceipt, +} from "./native-hack-local-route.ts"; +import { exec, run } from "./shell.ts"; + +const IDENTITY = /^\d+:\d+$/; +const IP_ADDRESS_LINE = /^ip_address:\s*(\S+)\s*$/gm; +const PROBE_TIMEOUT_MS = 1500; +const RECEIPT_FILENAME = "hack-local-route.json"; +const LOCK_HOLDER = /^(\d+)\n$/; + +/** Private receipt location under the native runtime home. */ +export function hackLocalRouteReceiptPath(runtimeHome: string): string { + return join(runtimeHome, "native-dns", RECEIPT_FILENAME); +} + +async function syncDirectory(path: string): Promise { + const directory = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + ); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +function isErrno(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +async function unlinkIfPresent(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (!isErrno(error, "ENOENT")) { + throw error; + } + } +} + +/** Read a small lock file without following links; null when it is gone. */ +async function readLockFile(path: string): Promise { + let handle: Awaited>; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if (isErrno(error, "ENOENT")) { + return null; + } + throw error; + } + try { + const buffer = Buffer.alloc(32); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await handle.close(); + } +} + +/** + * Write bytes to a fresh sibling and rename it over target only if `guard` + * still passes immediately before publication. The rename is atomic, so an + * interrupted write leaves either the old or the new complete file. + */ +async function replaceAtomically(opts: { + readonly path: string; + readonly content: string; + readonly mode: number; + readonly guard: () => Promise; +}): Promise { + const temporary = join( + dirname(opts.path), + `.${basename(opts.path)}.hack-${randomUUID()}.tmp` + ); + const handle = await open( + temporary, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + opts.mode + ); + try { + try { + await handle.writeFile(opts.content, "utf8"); + await handle.chmod(opts.mode); + await handle.sync(); + } finally { + await handle.close(); + } + await opts.guard(); + await rename(temporary, opts.path); + await syncDirectory(dirname(opts.path)); + } finally { + await unlinkIfPresent(temporary); + } +} + +async function ensurePrivateDirectory( + path: string, + ownerUid: number | undefined +): Promise { + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (!isErrno(error, "EEXIST")) { + throw error; + } + } + const details = await lstat(path); + if ( + !details.isDirectory() || + (details.mode & 0o077) !== 0 || + details.uid !== ownerUid + ) { + throw new Error("hack.local repair receipt directory is not private"); + } +} + +export interface HackLocalRouteFileOptions { + readonly dnsmasqPath: string; + readonly includeDir: string; + readonly resolverPath: string; + readonly receiptPath: string; + /** Testing seam; production runs the bounded resolver script with sudo -n. */ + readonly runPrivileged?: NativeDnsCommandRunner; + /** Testing seam for the required owner; defaults to the current user. */ + readonly ownerUid?: number; +} + +/** True when a process with this pid exists (possibly another user's). */ +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isErrno(error, "EPERM"); + } +} + +/** + * Exclusive lock beside the receipt. The holder pid is written to a private + * sibling first and published with link(), which never replaces an existing + * lock, so a lock file is always complete. A lock whose holder no longer + * exists is stale and is taken over once. Two runs that both find the same + * stale lock could race; each run also needs an interactive prompt, so that + * window is not reachable in practice. + */ +async function acquireRouteLock(opts: { + readonly receiptPath: string; + readonly ownerUid: number | undefined; +}): Promise<(() => Promise) | null> { + const directory = dirname(opts.receiptPath); + await ensurePrivateDirectory(directory, opts.ownerUid); + const lockPath = `${opts.receiptPath}.lock`; + const staged = join(directory, `.${RECEIPT_FILENAME}.lock-${randomUUID()}`); + const handle = await open( + staged, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600 + ); + try { + await handle.writeFile(`${process.pid}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await link(staged, lockPath); + return async () => await unlinkIfPresent(lockPath); + } catch (error) { + if (!isErrno(error, "EEXIST")) { + throw error; + } + } + const holder = LOCK_HOLDER.exec((await readLockFile(lockPath)) ?? ""); + if (holder && processExists(Number(holder[1]))) { + return null; + } + await unlinkIfPresent(lockPath); + } + return null; + } finally { + await unlinkIfPresent(staged); + } +} + +type FileDependencies = Pick< + HackLocalRouteDependencies, + | "paths" + | "readReceipt" + | "writeReceipt" + | "readDnsmasq" + | "readIncludeFiles" + | "readResolver" + | "replaceDnsmasq" + | "createResolver" + | "removeResolverIfOwned" + | "acquireLock" +>; + +/** + * File effects rooted at explicit paths. The dnsmasq file must be a regular + * single-link file owned by the caller (as Homebrew installs it); its mode is + * preserved. Resolver changes use the existing privileged resolver script. + */ +export function createHackLocalRouteFileDependencies( + opts: HackLocalRouteFileOptions +): FileDependencies { + const ownerUid = opts.ownerUid ?? process.getuid?.(); + return { + paths: { + dnsmasqPath: opts.dnsmasqPath, + includeDir: opts.includeDir, + resolverPath: opts.resolverPath, + receiptPath: opts.receiptPath, + }, + readReceipt: async () => await readStableNativeDnsFile(opts.receiptPath), + async writeReceipt(receipt: HackLocalRouteReceipt) { + await ensurePrivateDirectory(dirname(opts.receiptPath), ownerUid); + await replaceAtomically({ + path: opts.receiptPath, + content: `${JSON.stringify(receipt, null, 2)}\n`, + mode: 0o600, + guard: async () => undefined, + }); + }, + readDnsmasq: async () => await readStableNativeDnsFile(opts.dnsmasqPath), + async readIncludeFiles() { + let names: string[]; + try { + names = await readdir(opts.includeDir); + } catch (error) { + if (isErrno(error, "ENOENT")) { + return []; + } + throw error; + } + const files: { path: string; content: string }[] = []; + for (const name of names + .filter((entry) => entry.endsWith(".conf")) + .sort()) { + const path = join(opts.includeDir, name); + const content = await readStableNativeDnsFile(path); + if (content !== null) { + files.push({ path, content }); + } + } + return files; + }, + readResolver: async () => await readStableNativeDnsFile(opts.resolverPath), + async replaceDnsmasq({ expected, next }) { + const details = await lstat(opts.dnsmasqPath); + if ( + !details.isFile() || + details.nlink !== 1 || + details.uid !== ownerUid + ) { + throw new Error( + `${opts.dnsmasqPath} is not a regular file owned by the current user; refusing to rewrite it` + ); + } + await replaceAtomically({ + path: opts.dnsmasqPath, + content: next, + mode: details.mode & 0o7777, + guard: async () => { + const current = await readStableNativeDnsFile(opts.dnsmasqPath); + if (current !== expected) { + throw new Error( + "dnsmasq configuration changed before replacement; nothing was written" + ); + } + }, + }); + if ((await readStableNativeDnsFile(opts.dnsmasqPath)) !== next) { + throw new Error( + "dnsmasq configuration did not contain the written bytes" + ); + } + }, + async createResolver(content) { + const identity = await runPrivilegedResolverOperation({ + action: "create", + file: { path: opts.resolverPath, content }, + runPrivileged: opts.runPrivileged, + }); + if (!IDENTITY.test(identity)) { + throw new Error("Resolver create returned no valid ownership identity"); + } + return identity; + }, + acquireLock: async () => + await acquireRouteLock({ receiptPath: opts.receiptPath, ownerUid }), + async removeResolverIfOwned({ identity, content }) { + const result = await runPrivilegedResolverOperation({ + action: "remove", + file: { path: opts.resolverPath, content, identity }, + runPrivileged: opts.runPrivileged, + }); + if (result !== "0" && result !== "1") { + throw new Error("Resolver remove returned an invalid result"); + } + return result === "1"; + }, + }; +} + +/** Bounded TCP connect; any error or timeout means the target did not answer. */ +export async function probeTcp(opts: { + readonly host: string; + readonly port: number; + readonly timeoutMs?: number; +}): Promise { + return await new Promise((resolvePromise) => { + const socket = createConnection({ + host: opts.host, + port: opts.port, + timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS, + }); + const finish = (answered: boolean) => { + socket.destroy(); + resolvePromise(answered); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.once("timeout", () => finish(false)); + }); +} + +async function runCommand(command: readonly string[]) { + return await exec(command, { stdin: "ignore", timeoutMs: 5000 }); +} + +async function resolveBrewPrefix(): Promise { + const result = await exec(["brew", "--prefix"], { stdin: "ignore" }); + const prefix = result.exitCode === 0 ? result.stdout.trim() : ""; + return prefix.length > 0 ? prefix : "/opt/homebrew"; +} + +async function privileged(command: readonly string[], action: string) { + const result = await runNativeDnsAttachedCommand({ command }); + if (result.exitCode !== 0) { + throw new Error(`${action} failed (exit ${result.exitCode})`); + } +} + +async function verifyLiveHackLocalDns(): Promise { + const name = `hack-probe-${randomUUID().replaceAll("-", "")}.${HACK_LOCAL_DOMAIN}`; + const direct = await runCommand([ + "/usr/bin/dig", + "+short", + "+time=2", + "+tries=1", + `@${DEFAULT_HOST_DNS_IP}`, + name, + "A", + ]); + if (direct.exitCode !== 0 || direct.stdout.trim() !== DEFAULT_HOST_DNS_IP) { + throw new Error( + `dnsmasq did not answer ${name} with ${DEFAULT_HOST_DNS_IP}` + ); + } + const system = await runCommand([ + "/usr/bin/dscacheutil", + "-q", + "host", + "-a", + "name", + name, + ]); + const addresses = [...system.stdout.matchAll(IP_ADDRESS_LINE)].map( + (match) => match[1] + ); + if ( + system.exitCode !== 0 || + addresses.length === 0 || + addresses.some((address) => address !== DEFAULT_HOST_DNS_IP) + ) { + throw new Error(`macOS did not resolve ${name} to ${DEFAULT_HOST_DNS_IP}`); + } +} + +/** + * Production wiring for the native macOS host: Homebrew dnsmasq, the + * /etc/resolver directory, the verified native relay owner, the system + * launchd dnsmasq job and the native administrator prompt. + */ +export async function createNativeHackLocalRouteDependencies(opts: { + readonly runtime: NativeRuntimeSelection; +}): Promise { + const prefix = await resolveBrewPrefix(); + const dnsmasqPath = resolve(prefix, "etc", "dnsmasq.conf"); + const includeDir = resolve(prefix, "etc", "dnsmasq.d"); + const files = createHackLocalRouteFileDependencies({ + dnsmasqPath, + includeDir, + resolverPath: join("/etc/resolver", HACK_LOCAL_DOMAIN), + receiptPath: hackLocalRouteReceiptPath(opts.runtime.home), + }); + return { + ...files, + async verifyRelay() { + try { + const owner = await inspectActiveNativeHttpsOwner({ + runtime: opts.runtime, + }); + const listening = await probeTcp({ + host: DEFAULT_HOST_DNS_IP, + port: owner.httpsPort, + }); + return listening ? { port: owner.httpsPort } : null; + } catch { + return null; + } + }, + probeTarget: async (target) => await probeTcp({ host: target, port: 443 }), + canPrompt: () => canPrompt(), + async authorize(message) { + const accepted = await confirmSafe({ + message, + initialValue: false, + nonInteractive: "decline", + }); + if (!accepted) { + return false; + } + return ( + (await run(["sudo", "-v"], { + stdin: "inherit", + timeoutMs: 120_000, + forwardSignals: true, + })) === 0 + ); + }, + confirm: async (message) => + await confirmSafe({ + message, + initialValue: false, + nonInteractive: "decline", + }), + async prepareRestart() { + const host = await inspectNativeDnsHost({ brewPrefix: prefix }); + if (host.mainConfig.path !== dnsmasqPath) { + throw new Error( + "Running dnsmasq does not load the inspected configuration" + ); + } + return await prepareNativeDnsLaunchdRestart({ + dnsmasqBinary: host.dnsmasqArgs[0] ?? "", + mainConfigPath: host.mainConfig.path, + includeDir: host.includeDir, + inspectedArgs: host.dnsmasqArgs, + }); + }, + async flushDnsCache() { + await privileged( + ["sudo", "-n", "/usr/bin/dscacheutil", "-flushcache"], + "dscacheutil -flushcache" + ); + await privileged( + ["sudo", "-n", "/usr/bin/killall", "-HUP", "mDNSResponder"], + "killall -HUP mDNSResponder" + ); + }, + verifyLiveDns: verifyLiveHackLocalDns, + now: () => new Date(), + }; +} diff --git a/src/lib/native-hack-local-route.ts b/src/lib/native-hack-local-route.ts new file mode 100644 index 000000000..73669a950 --- /dev/null +++ b/src/lib/native-hack-local-route.ts @@ -0,0 +1,1323 @@ +import { createHash } from "node:crypto"; +import { CliUsageError } from "../cli/command.ts"; +import { + DEFAULT_CADDY_IP, + DEFAULT_HOST_DNS_IP, + DEFAULT_NEW_PROJECT_TLD, + DEFAULT_OAUTH_ALIAS_ROOT, + DEFAULT_PROJECT_TLD, +} from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import { findUninspectedDnsmasqSource } from "./native-domain-dns-plan.ts"; + +/** + * Native repair of the built-in `*.hack.local` route. + * + * Under the native backend the HTTPS relay listens on 127.0.0.1, while an older + * Docker-era install may still route `.hack.local` to Docker Caddy + * (172.30.0.2). This module decides whether the single Hack-managed + * `address=/.hack.local/` dnsmasq line may be rewritten in place to + * 127.0.0.1, applies that rewrite through injected effects, and restores the + * exact earlier line from a private receipt. + * + * Invariants: + * - Only the one managed line changes; every other byte of the file is kept. + * No second line for the suffix is ever added. + * - A rewrite needs a verified native relay, a dead old target and a fresh + * inspection after authorization. Every unmet precondition is a closed + * refusal reason, never a best-effort change. + * - A pending receipt is published before any host effect. An interrupted run + * leaves it for explicit recovery through the rollback command. When the + * recorded file has changed since, that command releases the receipt after + * confirmation instead of writing, so recovery can never get stuck. + * - Repair and rollback hold an exclusive lock for their whole mutation, so an + * automatic restore never mistakes another run's write for its own. + * - Strings are lossless decodings of the file bytes (BOM included), so every + * hash equals the SHA-256 of the raw file. + * - Diagnostics never echo free-form file contents. Lines appear only in the + * validated managed form, and every interpolated value is control-free. + */ + +export const HACK_LOCAL_DOMAIN = DEFAULT_NEW_PROJECT_TLD; +export const HACK_LOCAL_RESOLVER_CONTENT = `nameserver ${DEFAULT_HOST_DNS_IP}\n`; +export const HACK_LOCAL_RELAY_LINE = `address=/.${HACK_LOCAL_DOMAIN}/${DEFAULT_HOST_DNS_IP}`; +export const HACK_LOCAL_REPAIR_COMMAND = "hack doctor --fix"; +export const HACK_LOCAL_ROLLBACK_COMMAND = `hack global dns deactivate --domain ${HACK_LOCAL_DOMAIN}`; + +const LINE_PREFIX = `address=/.${HACK_LOCAL_DOMAIN}/`; +/** Former Hack-written targets that may be replaced once proven dead. */ +const REPLACEABLE_TARGETS: ReadonlySet = new Set([ + DEFAULT_CADDY_IP, + "::1", +]); +/** Every line a repair can record as the earlier line. */ +const RECORDABLE_OLD_LINES: ReadonlySet = new Set( + [DEFAULT_HOST_DNS_IP, ...REPLACEABLE_TARGETS].map( + (target) => `${LINE_PREFIX}${target}` + ) +); +const TARGET_FORMAT = /^[0-9a-f:.]+$/i; +const DOMAIN_TOKEN = /[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi; +const SHA256 = /^[0-9a-f]{64}$/; +const RESOLVER_IDENTITY = /^\d+:\d+$/; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g; +const BUILT_IN_ROOTS: ReadonlySet = new Set([ + DEFAULT_PROJECT_TLD, + DEFAULT_NEW_PROJECT_TLD, + DEFAULT_OAUTH_ALIAS_ROOT, +]); + +/** Closed set of reasons a repair or rollback does not change the host. */ +export type HackLocalRouteRefusal = + | "invalid-receipt" + | "pending-receipt" + | "relay-not-verified" + | "dnsmasq-config-missing" + /** A DNS file is not a bounded, unlinked regular UTF-8 file. */ + | "unreadable-config" + /** dnsmasq loads a file, directory or program Hack did not inspect. */ + | "uninspected-config-source" + | "no-managed-line" + | "unmanaged-line" + | "foreign-target" + | "foreign-resolver" + | "old-target-live" + | "non-interactive" + | "declined" + | "busy" + | "restart-unavailable" + | "inputs-changed" + | "no-receipt"; + +export interface HackLocalRouteReceipt { + readonly version: 1; + readonly kind: "hack-local-route-repair"; + /** + * `released`: the recorded file changed after the repair, and the user + * chose to keep the current bytes instead of restoring the earlier line. + */ + readonly state: "pending" | "applied" | "rolled-back" | "released"; + readonly domain: typeof HACK_LOCAL_DOMAIN; + readonly dnsmasqPath: string; + readonly resolverPath: string; + readonly oldLine: string; + readonly newLine: string; + /** SHA-256 of the whole dnsmasq file before and after the line rewrite. */ + readonly beforeSha256: string; + readonly afterSha256: string; + readonly resolverAction: "keep" | "create"; + readonly resolverSha256: string; + /** Identity of a resolver this repair created; null until creation is confirmed. */ + readonly resolverIdentity: string | null; + readonly relayPort: number; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface HackLocalRoutePaths { + readonly dnsmasqPath: string; + /** The include directory dnsmasq loads as `,*.conf`. */ + readonly includeDir: string; + readonly resolverPath: string; + readonly receiptPath: string; +} + +/** Every host read and effect is supplied by the caller. */ +export interface HackLocalRouteDependencies { + readonly paths: HackLocalRoutePaths; + readonly readReceipt: () => Promise; + /** Atomically and durably publish the private receipt. */ + readonly writeReceipt: (receipt: HackLocalRouteReceipt) => Promise; + readonly readDnsmasq: () => Promise; + readonly readIncludeFiles: () => Promise< + readonly { readonly path: string; readonly content: string }[] + >; + readonly readResolver: () => Promise; + /** Atomic replacement that refuses unless the current bytes equal expected. */ + readonly replaceDnsmasq: (opts: { + readonly expected: string; + readonly next: string; + }) => Promise; + /** No-clobber privileged create; returns the created file identity. */ + readonly createResolver: (content: string) => Promise; + /** Privileged removal of an exact identity and content match only. */ + readonly removeResolverIfOwned: (opts: { + readonly identity: string; + readonly content: string; + }) => Promise; + /** The active native relay owner and its listening 127.0.0.1 port, or null. */ + readonly verifyRelay: () => Promise<{ readonly port: number } | null>; + /** Bounded probe: true when the old target still answers on 443. */ + readonly probeTarget: (target: string) => Promise; + readonly canPrompt: () => boolean; + /** Human confirmation plus the native administrator prompt. */ + readonly authorize: (message: string) => Promise; + /** Human confirmation only, for a change limited to the private receipt. */ + readonly confirm: (message: string) => Promise; + /** + * Take the exclusive repair/rollback lock. Returns its release, or null + * when another live run holds it. + */ + readonly acquireLock: () => Promise<(() => Promise) | null>; + /** Validate the dnsmasq service before effects; return its restart. */ + readonly prepareRestart: () => Promise<() => Promise>; + readonly flushDnsCache: () => Promise; + readonly verifyLiveDns: () => Promise; + readonly now: () => Date; +} + +export interface HackLocalRoutePlan { + readonly paths: HackLocalRoutePaths; + readonly oldLine: string; + readonly newLine: string; + readonly oldTarget: string; + readonly beforeContent: string; + readonly afterContent: string; + readonly beforeSha256: string; + readonly afterSha256: string; + readonly resolverAction: "keep" | "create"; + readonly relayPort: number; + readonly fingerprint: string; +} + +export type HackLocalRouteInspection = + | { + readonly status: "current"; + readonly paths: HackLocalRoutePaths; + readonly relayPort: number; + readonly receipt: HackLocalRouteReceipt | null; + } + | { + readonly status: "repairable"; + readonly plan: HackLocalRoutePlan; + readonly receipt: HackLocalRouteReceipt | null; + } + | { + readonly status: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly paths: HackLocalRoutePaths; + readonly receipt: HackLocalRouteReceipt | null; + }; + +export type HackLocalRouteRepairResult = + | { + readonly outcome: "repaired"; + readonly plan: HackLocalRoutePlan; + readonly receipt: HackLocalRouteReceipt; + } + | { readonly outcome: "current"; readonly relayPort: number } + | { + readonly outcome: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly command?: string; + }; + +export type HackLocalRouteRollbackResult = + | { + readonly outcome: "rolled-back"; + readonly receipt: HackLocalRouteReceipt; + /** A resolver this repair created but could not prove it still owns. */ + readonly retainedResolver: string | null; + /** + * Null when dnsmasq was restarted. Otherwise why the restored file + * was not loaded (for example dnsmasq is not running). + */ + readonly restartSkipped: string | null; + } + | { + /** The file changed after the repair; nothing but the receipt changed. */ + readonly outcome: "released"; + readonly receipt: HackLocalRouteReceipt; + readonly detail: string; + } + | { + readonly outcome: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly command?: string; + }; + +/** + * JSON-safe summary for doctor and preview. It never includes free-form file + * contents: `oldLine` and `newLine` appear only in the validated managed form, + * and `detail` names lines by number. + */ +export interface HackLocalRouteReport { + readonly status: "current" | "repairable" | "refused"; + readonly reason?: HackLocalRouteRefusal; + readonly detail?: string; + readonly oldLine?: string; + readonly newLine?: string; + readonly resolverAction?: "keep" | "create"; + readonly relayPort?: number; + readonly dnsmasqPath: string; + readonly resolverPath: string; + readonly receiptPath: string; + readonly receiptState: HackLocalRouteReceipt["state"] | null; + readonly command?: string; +} + +/** + * SHA-256 of the UTF-8 encoding. File strings come from a fatal decoder that + * keeps a leading BOM, so this equals the hash of the raw file bytes. + */ +export function sha256(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +/** Replace control characters so a value cannot drive the terminal. */ +export function printable(value: string): string { + return value.replace(CONTROL_CHARACTERS, "?"); +} + +interface ConfigLine { + /** Line text without its terminator (a trailing CR is excluded too). */ + readonly text: string; + readonly start: number; + readonly end: number; +} + +function configLines(content: string): readonly ConfigLine[] { + const lines: ConfigLine[] = []; + let start = 0; + while (start <= content.length) { + const newline = content.indexOf("\n", start); + const lineEnd = newline < 0 ? content.length : newline; + const end = + lineEnd > start && content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd; + lines.push({ text: content.slice(start, end), start, end }); + if (newline < 0) { + break; + } + start = newline + 1; + } + return lines; +} + +/** True when a non-comment dnsmasq directive names hack.local or a subdomain. */ +function claimsHackLocal(text: string): boolean { + const line = text.trim(); + if (!line || line.startsWith("#")) { + return false; + } + const equal = line.indexOf("="); + if (equal < 0) { + return false; + } + const value = line.slice(equal + 1).split("#", 1)[0] ?? ""; + return (value.match(DOMAIN_TOKEN) ?? []).some((token) => { + const domain = token.toLowerCase(); + return ( + domain === HACK_LOCAL_DOMAIN || domain.endsWith(`.${HACK_LOCAL_DOMAIN}`) + ); + }); +} + +/** Replace exactly one whole line; null when the line is absent or repeated. */ +export function replaceManagedLine(opts: { + readonly content: string; + readonly from: string; + readonly to: string; +}): string | null { + const matches = configLines(opts.content).filter( + (line) => line.text === opts.from + ); + if (matches.length !== 1) { + return null; + } + const [line] = matches as [ConfigLine]; + return `${opts.content.slice(0, line.start)}${opts.to}${opts.content.slice(line.end)}`; +} + +type DnsmasqAnalysis = + | { + readonly kind: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + } + | { readonly kind: "line"; readonly line: string; readonly target: string }; + +/** Pure classification of the managed line; exported for focused tests. */ +export function analyzeHackLocalDnsmasq(opts: { + readonly content: string | null; + /** Path of the main configuration, named in diagnostics only. */ + readonly path: string; + readonly includeDir: string; + readonly includeFiles: readonly { + readonly path: string; + readonly content: string; + }[]; +}): DnsmasqAnalysis { + if (opts.content === null) { + return { + kind: "refused", + reason: "dnsmasq-config-missing", + detail: "The dnsmasq configuration does not exist.", + }; + } + // Any file dnsmasq would also load could claim hack.local out of sight, so + // the main file and every inspected include must load nothing else. + for (const file of [ + { path: opts.path, content: opts.content }, + ...opts.includeFiles, + ]) { + const source = findUninspectedDnsmasqSource({ + content: file.content, + includeDir: opts.includeDir, + }); + if (source) { + return { + kind: "refused", + reason: "uninspected-config-source", + detail: printable( + `${file.path} line ${source.line} uses ${source.directive}, which loads DNS data Hack did not inspect; refusing to change the hack.local route.` + ), + }; + } + } + const included = opts.includeFiles.find((file) => + configLines(file.content).some((line) => claimsHackLocal(line.text)) + ); + if (included) { + return { + kind: "refused", + reason: "unmanaged-line", + detail: `${printable(included.path)} also claims .${HACK_LOCAL_DOMAIN}; Hack manages only the main configuration line.`, + }; + } + const claims = configLines(opts.content) + .map((line, index) => ({ ...line, number: index + 1 })) + .filter((line) => claimsHackLocal(line.text)); + if (claims.length === 0) { + return { + kind: "refused", + reason: "no-managed-line", + detail: `No ${LINE_PREFIX} line exists; the native repair never adds one.`, + }; + } + if (claims.length > 1) { + return { + kind: "refused", + reason: "unmanaged-line", + detail: `${claims.length} lines claim .${HACK_LOCAL_DOMAIN}; only a single Hack-managed line can be rewritten.`, + }; + } + const claim = claims[0] as ConfigLine & { readonly number: number }; + const line = claim.text; + const target = line.startsWith(LINE_PREFIX) + ? line.slice(LINE_PREFIX.length) + : ""; + if (!TARGET_FORMAT.test(target)) { + return { + kind: "refused", + reason: "unmanaged-line", + detail: `Line ${claim.number} claims .${HACK_LOCAL_DOMAIN} but is not in the Hack-managed ${LINE_PREFIX}
form.`, + }; + } + if (target !== DEFAULT_HOST_DNS_IP && !REPLACEABLE_TARGETS.has(target)) { + return { + kind: "refused", + reason: "foreign-target", + detail: `Line ${claim.number} routes .${HACK_LOCAL_DOMAIN} to an address Hack never wrote; refusing to replace it.`, + }; + } + return { kind: "line", line, target }; +} + +function analyzeResolver( + content: string | null +): "keep" | "create" | "foreign" { + if (content === null) { + return "create"; + } + return content.trim() === HACK_LOCAL_RESOLVER_CONTENT.trim() + ? "keep" + : "foreign"; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length < 4096; +} + +/** A single-line absolute path; receipts never carry control characters. */ +function receiptPathValue(value: unknown): value is string { + return ( + nonEmptyString(value) && value.startsWith("/") && printable(value) === value + ); +} + +/** Strict receipt parsing; any unknown shape refuses rather than guessing ownership. */ +export function parseHackLocalRouteReceipt(raw: string): HackLocalRouteReceipt { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("hack.local repair receipt is not valid JSON"); + } + const ok = + isRecord(value) && + value.version === 1 && + value.kind === "hack-local-route-repair" && + (value.state === "pending" || + value.state === "applied" || + value.state === "rolled-back" || + value.state === "released") && + value.domain === HACK_LOCAL_DOMAIN && + receiptPathValue(value.dnsmasqPath) && + receiptPathValue(value.resolverPath) && + typeof value.oldLine === "string" && + RECORDABLE_OLD_LINES.has(value.oldLine) && + value.newLine === HACK_LOCAL_RELAY_LINE && + typeof value.beforeSha256 === "string" && + SHA256.test(value.beforeSha256) && + typeof value.afterSha256 === "string" && + SHA256.test(value.afterSha256) && + (value.resolverAction === "keep" || value.resolverAction === "create") && + typeof value.resolverSha256 === "string" && + SHA256.test(value.resolverSha256) && + (value.resolverIdentity === null || + (typeof value.resolverIdentity === "string" && + RESOLVER_IDENTITY.test(value.resolverIdentity))) && + typeof value.relayPort === "number" && + Number.isInteger(value.relayPort) && + value.relayPort > 0 && + value.relayPort < 65_536 && + nonEmptyString(value.createdAt) && + nonEmptyString(value.updatedAt); + if (!ok) { + throw new Error("hack.local repair receipt has an unexpected shape"); + } + return value as unknown as HackLocalRouteReceipt; +} + +type StoredReceipt = + | { + readonly raw: string | null; + readonly receipt: HackLocalRouteReceipt | null; + } + | { readonly invalid: string }; + +async function readReceipt( + deps: HackLocalRouteDependencies +): Promise { + const raw = await deps.readReceipt(); + if (raw === null) { + return { raw, receipt: null }; + } + try { + return { raw, receipt: parseHackLocalRouteReceipt(raw) }; + } catch (error) { + return { invalid: error instanceof Error ? error.message : String(error) }; + } +} + +function refused(opts: { + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly paths: HackLocalRoutePaths; + readonly receipt: HackLocalRouteReceipt | null; +}): HackLocalRouteInspection { + return { status: "refused", ...opts }; +} + +const UNREADABLE_DETAIL = + "The dnsmasq configuration, an included .conf file or the resolver is not a bounded, unlinked regular UTF-8 file (for example a symlink or invalid UTF-8); refusing to change it."; + +/** + * Read the DNS files an inspection needs. A file that cannot be read safely + * (a symlink rejected by O_NOFOLLOW, invalid UTF-8, a linked or oversized + * file) yields null so the caller refuses with a closed reason. The read + * error itself is dropped: it is never needed and must not leak content. + */ +async function readDnsFiles(deps: HackLocalRouteDependencies): Promise<{ + readonly content: string | null; + readonly includeFiles: readonly { + readonly path: string; + readonly content: string; + }[]; + readonly resolver: string | null; +} | null> { + try { + const [content, includeFiles, resolver] = await Promise.all([ + deps.readDnsmasq(), + deps.readIncludeFiles(), + deps.readResolver(), + ]); + return { content, includeFiles, resolver }; + } catch { + return null; + } +} + +/** + * Read-only inspection. Preconditions are checked in a fixed order so that a + * missing relay never reads host DNS files and a live old target is never + * replaced: receipt, relay, managed line, resolver, then the old-target probe. + */ +export async function inspectHackLocalRoute( + deps: HackLocalRouteDependencies +): Promise { + const { paths } = deps; + const stored = await readReceipt(deps); + if ("invalid" in stored) { + return refused({ + reason: "invalid-receipt", + detail: printable( + `${stored.invalid}; refusing to infer ownership of ${paths.receiptPath}.` + ), + paths, + receipt: null, + }); + } + const { receipt } = stored; + if (receipt?.state === "pending") { + return refused({ + reason: "pending-receipt", + detail: `An interrupted hack.local repair left a pending receipt. Run ${HACK_LOCAL_ROLLBACK_COMMAND} to restore the recorded earlier line, or, if dnsmasq.conf changed since, to release the receipt without writing.`, + paths, + receipt, + }); + } + const relay = await deps.verifyRelay(); + if (!relay) { + return refused({ + reason: "relay-not-verified", + detail: + "No verified native HTTPS relay is listening on 127.0.0.1; start a native project before repairing *.hack.local.", + paths, + receipt, + }); + } + const files = await readDnsFiles(deps); + if (!files) { + return refused({ + reason: "unreadable-config", + detail: UNREADABLE_DETAIL, + paths, + receipt, + }); + } + const { content, includeFiles, resolver } = files; + const analysis = analyzeHackLocalDnsmasq({ + content, + path: paths.dnsmasqPath, + includeDir: paths.includeDir, + includeFiles, + }); + if (analysis.kind === "refused") { + return refused({ ...analysis, paths, receipt }); + } + const resolverAction = analyzeResolver(resolver); + if (resolverAction === "foreign") { + return refused({ + reason: "foreign-resolver", + detail: printable( + `${paths.resolverPath} exists with content Hack did not write; refusing to change it.` + ), + paths, + receipt, + }); + } + const beforeContent = content as string; + if (analysis.target === DEFAULT_HOST_DNS_IP && resolverAction === "keep") { + return { + status: "current", + paths, + relayPort: relay.port, + receipt, + }; + } + if ( + analysis.target !== DEFAULT_HOST_DNS_IP && + (await deps.probeTarget(analysis.target)) + ) { + return refused({ + reason: "old-target-live", + detail: `${analysis.target}:443 still answers (Docker Caddy may be running); refusing to break the live route.`, + paths, + receipt, + }); + } + const afterContent = + analysis.target === DEFAULT_HOST_DNS_IP + ? beforeContent + : replaceManagedLine({ + content: beforeContent, + from: analysis.line, + to: HACK_LOCAL_RELAY_LINE, + }); + if (afterContent === null) { + return refused({ + reason: "unmanaged-line", + detail: "The managed line could not be located exactly once.", + paths, + receipt, + }); + } + const fingerprint = sha256( + JSON.stringify({ + beforeContent, + includeFiles, + resolver, + receipt: stored.raw, + relayPort: relay.port, + }) + ); + return { + status: "repairable", + receipt, + plan: { + paths, + oldLine: analysis.line, + newLine: HACK_LOCAL_RELAY_LINE, + oldTarget: analysis.target, + beforeContent, + afterContent, + beforeSha256: sha256(beforeContent), + afterSha256: sha256(afterContent), + resolverAction, + relayPort: relay.port, + fingerprint, + }, + }; +} + +/** Summarize an inspection for doctor and preview output. */ +export function reportHackLocalRoute( + inspection: HackLocalRouteInspection +): HackLocalRouteReport { + const paths = + inspection.status === "repairable" + ? inspection.plan.paths + : inspection.paths; + const base = { + dnsmasqPath: paths.dnsmasqPath, + resolverPath: paths.resolverPath, + receiptPath: paths.receiptPath, + receiptState: inspection.receipt?.state ?? null, + }; + if (inspection.status === "current") { + return { + ...base, + status: "current", + relayPort: inspection.relayPort, + ...(inspection.receipt?.state === "applied" + ? { command: HACK_LOCAL_ROLLBACK_COMMAND } + : {}), + }; + } + if (inspection.status === "repairable") { + return { + ...base, + status: "repairable", + oldLine: inspection.plan.oldLine, + newLine: inspection.plan.newLine, + resolverAction: inspection.plan.resolverAction, + relayPort: inspection.plan.relayPort, + command: HACK_LOCAL_REPAIR_COMMAND, + }; + } + return { + ...base, + status: "refused", + reason: inspection.reason, + detail: inspection.detail, + ...(inspection.reason === "pending-receipt" + ? { command: HACK_LOCAL_ROLLBACK_COMMAND } + : {}), + }; +} + +/** One-line human message for a report; control characters are replaced. */ +export function describeHackLocalRoute(report: HackLocalRouteReport): string { + if (report.status === "current") { + return printable( + `*.${HACK_LOCAL_DOMAIN} routes to the native relay on 127.0.0.1:${report.relayPort}.${ + report.receiptState === "applied" + ? ` Undo the native repair with: ${HACK_LOCAL_ROLLBACK_COMMAND}` + : "" + }` + ); + } + if (report.status === "repairable") { + const resolver = + report.resolverAction === "create" + ? `create ${report.resolverPath} with "nameserver 127.0.0.1"` + : ""; + const rewrite = + report.oldLine === report.newLine + ? "" + : `rewrite "${report.oldLine}" to "${report.newLine}" in ${report.dnsmasqPath}`; + const change = [rewrite, resolver].filter(Boolean).join(" and "); + return printable( + `Proposed: ${change}. Apply with: ${HACK_LOCAL_REPAIR_COMMAND}` + ); + } + return printable(`Not repairable (${report.reason}): ${report.detail}`); +} + +function samePlan( + left: HackLocalRoutePlan, + right: HackLocalRoutePlan +): boolean { + return ( + left.fingerprint === right.fingerprint && + left.beforeSha256 === right.beforeSha256 && + left.afterSha256 === right.afterSha256 && + left.oldLine === right.oldLine && + left.resolverAction === right.resolverAction && + left.relayPort === right.relayPort && + left.paths.dnsmasqPath === right.paths.dnsmasqPath && + left.paths.includeDir === right.paths.includeDir && + left.paths.resolverPath === right.paths.resolverPath && + left.paths.receiptPath === right.paths.receiptPath + ); +} + +function errorMessage(error: unknown): string { + return printable( + error instanceof Error ? error.message : String(error) + ).slice(0, 512); +} + +/** How the recorded earlier bytes relate to the current file. */ +export type HackLocalRestoreStep = + /** The file holds the repaired bytes; `next` reproduces the earlier hash. */ + | { readonly kind: "write"; readonly next: string } + /** The file already holds the earlier bytes. */ + | { readonly kind: "unchanged" } + /** The file matches neither recorded hash, or is missing. */ + | { readonly kind: "drift" } + /** The receipt's lines do not reproduce its own recorded hashes. */ + | { readonly kind: "mismatch" }; + +/** Pure restore decision shared by the precheck and the effect. */ +export function planHackLocalRestore(opts: { + readonly current: string | null; + readonly receipt: HackLocalRouteReceipt; +}): HackLocalRestoreStep { + const { current, receipt } = opts; + if (current === null) { + return { kind: "drift" }; + } + const currentSha = sha256(current); + if (currentSha === receipt.beforeSha256) { + return { kind: "unchanged" }; + } + if (currentSha !== receipt.afterSha256) { + return { kind: "drift" }; + } + const next = replaceManagedLine({ + content: current, + from: receipt.newLine, + to: receipt.oldLine, + }); + if (next === null || sha256(next) !== receipt.beforeSha256) { + return { kind: "mismatch" }; + } + return { kind: "write", next }; +} + +interface RestoreOutcome { + readonly retainedResolver: string | null; + /** True when restore wrote dnsmasq.conf or removed the resolver. */ + readonly changed: boolean; + readonly restarted: boolean; +} + +/** + * Restore the recorded earlier state. Shared by explicit rollback and by + * automatic cleanup when a repair fails. dnsmasq restarts only when this + * restore changed something or `forceRestart` says the running service may + * hold the repaired configuration; `restart` null skips it. + */ +async function restoreFromReceipt(opts: { + readonly deps: HackLocalRouteDependencies; + readonly receipt: HackLocalRouteReceipt; + readonly restart: (() => Promise) | null; + readonly forceRestart: boolean; +}): Promise { + const { deps, receipt } = opts; + const current = await deps.readDnsmasq(); + const step = planHackLocalRestore({ current, receipt }); + let changed = false; + if (step.kind === "write") { + await deps.replaceDnsmasq({ expected: current as string, next: step.next }); + const verified = await deps.readDnsmasq(); + if (verified === null || sha256(verified) !== receipt.beforeSha256) { + throw new Error("Restored dnsmasq configuration hash does not match"); + } + changed = true; + } else if (step.kind !== "unchanged") { + throw new Error( + step.kind === "drift" + ? "dnsmasq configuration changed since the repair" + : "Restored dnsmasq bytes would not match the receipt" + ); + } + let retainedResolver: string | null = null; + if (receipt.resolverAction === "create") { + const resolver = await deps.readResolver(); + if (resolver !== null) { + const removed = + receipt.resolverIdentity !== null && + (await deps.removeResolverIfOwned({ + identity: receipt.resolverIdentity, + content: HACK_LOCAL_RESOLVER_CONTENT, + })); + if (removed) { + changed = true; + } else { + retainedResolver = receipt.resolverPath; + } + } + } + let restarted = false; + if ((changed || opts.forceRestart) && opts.restart) { + await opts.restart(); + await deps.flushDnsCache(); + restarted = true; + } + return { retainedResolver, changed, restarted }; +} + +function stamp( + receipt: HackLocalRouteReceipt, + patch: Partial>, + now: Date +): HackLocalRouteReceipt { + return Object.freeze({ ...receipt, ...patch, updatedAt: now.toISOString() }); +} + +function busy(): { + readonly outcome: "refused"; + readonly reason: "busy"; + readonly detail: string; +} { + return { + outcome: "refused", + reason: "busy", + detail: + "Another hack.local repair or rollback is running; nothing changed. Retry when it finishes.", + }; +} + +/** + * Authorized, reversible repair. Rechecks the plan after the administrator + * prompt, publishes a pending receipt before effects, and on failure restores + * the recorded earlier line. If that cleanup cannot be proven the pending + * receipt stays for the explicit rollback command. + */ +export async function repairHackLocalRoute( + deps: HackLocalRouteDependencies +): Promise { + const before = await inspectHackLocalRoute(deps); + if (before.status === "current") { + return { outcome: "current", relayPort: before.relayPort }; + } + if (before.status === "refused") { + return { + outcome: "refused", + reason: before.reason, + detail: before.detail, + ...(before.reason === "pending-receipt" + ? { command: HACK_LOCAL_ROLLBACK_COMMAND } + : {}), + }; + } + const { plan } = before; + if (!deps.canPrompt()) { + return { + outcome: "refused", + reason: "non-interactive", + detail: `The repair needs interactive confirmation and a macOS administrator prompt. Run it in a terminal: ${HACK_LOCAL_REPAIR_COMMAND}`, + command: HACK_LOCAL_REPAIR_COMMAND, + }; + } + const release = await deps.acquireLock(); + if (!release) { + return busy(); + } + try { + return await repairUnderLock(deps, plan); + } finally { + await release(); + } +} + +async function repairUnderLock( + deps: HackLocalRouteDependencies, + plan: HackLocalRoutePlan +): Promise { + const changes = [ + plan.oldLine === plan.newLine + ? "" + : `rewrite only "${plan.oldLine}" to "${plan.newLine}" in ${plan.paths.dnsmasqPath}`, + plan.resolverAction === "create" + ? `create ${plan.paths.resolverPath} with "nameserver 127.0.0.1"` + : "", + ].filter(Boolean); + const probe = + plan.oldTarget === DEFAULT_HOST_DNS_IP + ? "" + : `${plan.oldTarget}:443 did not answer; `; + const approved = await deps.authorize( + printable( + `Native hack.local repair: ${changes.join(" and ")}? ${probe}the native relay listens on 127.0.0.1:${plan.relayPort}. This restarts dnsmasq. Undo: ${HACK_LOCAL_ROLLBACK_COMMAND}` + ) + ); + if (!approved) { + return { + outcome: "refused", + reason: "declined", + detail: "The hack.local repair was not authorized; nothing changed.", + }; + } + let restart: () => Promise; + try { + restart = await deps.prepareRestart(); + } catch (error) { + return { + outcome: "refused", + reason: "restart-unavailable", + detail: `The dnsmasq service cannot be restarted safely (${errorMessage(error)}); nothing changed.`, + }; + } + const after = await inspectHackLocalRoute(deps); + if (after.status !== "repairable" || !samePlan(plan, after.plan)) { + return { + outcome: "refused", + reason: "inputs-changed", + detail: + "DNS files, the relay or the old target changed during authorization; nothing changed.", + }; + } + + const createdAt = deps.now().toISOString(); + let receipt: HackLocalRouteReceipt = Object.freeze({ + version: 1, + kind: "hack-local-route-repair", + state: "pending", + domain: HACK_LOCAL_DOMAIN, + dnsmasqPath: plan.paths.dnsmasqPath, + resolverPath: plan.paths.resolverPath, + oldLine: plan.oldLine, + newLine: plan.newLine, + beforeSha256: plan.beforeSha256, + afterSha256: plan.afterSha256, + resolverAction: plan.resolverAction, + resolverSha256: sha256(HACK_LOCAL_RESOLVER_CONTENT), + resolverIdentity: null, + relayPort: plan.relayPort, + createdAt, + updatedAt: createdAt, + }); + await deps.writeReceipt(receipt); + + let phase = "dnsmasq rewrite"; + let restartAttempted = false; + try { + if (plan.afterContent !== plan.beforeContent) { + await deps.replaceDnsmasq({ + expected: plan.beforeContent, + next: plan.afterContent, + }); + } + if (plan.resolverAction === "create") { + phase = "resolver create"; + const identity = await deps.createResolver(HACK_LOCAL_RESOLVER_CONTENT); + receipt = stamp(receipt, { resolverIdentity: identity }, deps.now()); + await deps.writeReceipt(receipt); + } + phase = "dnsmasq restart"; + restartAttempted = true; + await restart(); + phase = "DNS cache flush"; + await deps.flushDnsCache(); + phase = "live DNS verification"; + await deps.verifyLiveDns(); + phase = "receipt"; + receipt = stamp(receipt, { state: "applied" }, deps.now()); + await deps.writeReceipt(receipt); + return { outcome: "repaired", plan, receipt }; + } catch (cause) { + let restored: RestoreOutcome; + try { + restored = await restoreFromReceipt({ + deps, + receipt, + restart, + forceRestart: restartAttempted, + }); + await deps.writeReceipt( + stamp(receipt, { state: "rolled-back" }, deps.now()) + ); + } catch (cleanup) { + throw new Error( + `hack.local repair failed during ${phase}: ${errorMessage(cause)}; automatic restore failed (${errorMessage(cleanup)}). Recover with: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + { cause } + ); + } + const state = + restored.changed || restored.restarted + ? "the earlier dnsmasq line was restored" + : "nothing was changed"; + throw new Error( + printable( + `hack.local repair failed during ${phase}: ${errorMessage(cause)}; ${state}${ + restored.retainedResolver + ? `; ${restored.retainedResolver} was retained because its ownership is unproven` + : "" + }.` + ), + { cause } + ); + } +} + +/** + * Restore the exact earlier line recorded by an applied or interrupted repair + * and verify the whole-file hash. When the file changed since the repair (or + * the receipt names another file), restoring would overwrite someone else's + * bytes, so the command instead offers to release the receipt: after a plain + * confirmation it marks the receipt `released` and changes nothing else. + */ +export async function rollbackHackLocalRoute( + deps: HackLocalRouteDependencies +): Promise { + const stored = await readReceipt(deps); + if ("invalid" in stored) { + return { + outcome: "refused", + reason: "invalid-receipt", + detail: printable( + `${stored.invalid}; refusing to infer ownership of ${deps.paths.receiptPath}.` + ), + }; + } + const { receipt, raw } = stored; + if ( + !receipt || + receipt.state === "rolled-back" || + receipt.state === "released" + ) { + return { + outcome: "refused", + reason: "no-receipt", + detail: printable( + `No applied or interrupted hack.local repair is recorded at ${deps.paths.receiptPath}; nothing to roll back.` + ), + }; + } + const release = await deps.acquireLock(); + if (!release) { + return busy(); + } + try { + return await rollbackUnderLock({ deps, receipt, raw }); + } finally { + await release(); + } +} + +async function rollbackUnderLock(opts: { + readonly deps: HackLocalRouteDependencies; + readonly receipt: HackLocalRouteReceipt; + readonly raw: string | null; +}): Promise { + const { deps, receipt } = opts; + let current: string | null; + try { + current = await deps.readDnsmasq(); + } catch { + return { + outcome: "refused", + reason: "unreadable-config", + detail: + "The dnsmasq configuration is not a bounded, unlinked regular UTF-8 file; nothing changed.", + }; + } + const samePaths = + receipt.dnsmasqPath === deps.paths.dnsmasqPath && + receipt.resolverPath === deps.paths.resolverPath; + const step: HackLocalRestoreStep = samePaths + ? planHackLocalRestore({ current, receipt }) + : { kind: "drift" }; + if (step.kind === "mismatch") { + return { + outcome: "refused", + reason: "invalid-receipt", + detail: + "The receipt's recorded line does not reproduce its recorded file hash; refusing to write it.", + }; + } + if (!deps.canPrompt()) { + return { + outcome: "refused", + reason: "non-interactive", + detail: `Rollback needs interactive confirmation. Run it in a terminal: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + command: HACK_LOCAL_ROLLBACK_COMMAND, + }; + } + if (step.kind === "drift") { + return await releaseReceipt({ ...opts, samePaths }); + } + const approved = await deps.authorize( + printable( + `Restore "${receipt.oldLine}" in ${receipt.dnsmasqPath}${ + receipt.resolverAction === "create" + ? ` and remove the ${receipt.resolverPath} this repair created` + : "" + }? This restarts dnsmasq.` + ) + ); + if (!approved) { + return { + outcome: "refused", + reason: "declined", + detail: "The hack.local rollback was not authorized; nothing changed.", + }; + } + // Restoring the file needs no service; a missing or ambiguous dnsmasq only + // means the restored line loads on its next start. + let restart: (() => Promise) | null = null; + let restartSkipped: string | null = null; + try { + restart = await deps.prepareRestart(); + } catch (error) { + restartSkipped = errorMessage(error); + } + const again = await deps.readReceipt(); + const recheck = await deps.readDnsmasq(); + if (again !== opts.raw || recheck !== current) { + return { + outcome: "refused", + reason: "inputs-changed", + detail: + "The receipt or dnsmasq configuration changed during authorization; nothing changed.", + }; + } + const restored = await restoreFromReceipt({ + deps, + receipt, + restart, + forceRestart: true, + }); + const rolledBack = stamp(receipt, { state: "rolled-back" }, deps.now()); + await deps.writeReceipt(rolledBack); + return { + outcome: "rolled-back", + receipt: rolledBack, + retainedResolver: restored.retainedResolver, + restartSkipped, + }; +} + +async function releaseReceipt(opts: { + readonly deps: HackLocalRouteDependencies; + readonly receipt: HackLocalRouteReceipt; + readonly raw: string | null; + readonly samePaths: boolean; +}): Promise { + const { deps, receipt } = opts; + const why = opts.samePaths + ? `${receipt.dnsmasqPath} changed after the hack.local repair` + : `The receipt names ${receipt.dnsmasqPath}, not the current ${deps.paths.dnsmasqPath}`; + const kept = + receipt.resolverAction === "create" ? ` and ${receipt.resolverPath}` : ""; + const approved = await deps.confirm( + printable( + `${why}, so restoring "${receipt.oldLine}" would overwrite other changes. Release the ${receipt.state} receipt and leave the dnsmasq configuration${kept} exactly as they are?` + ) + ); + if (!approved) { + return { + outcome: "refused", + reason: "declined", + detail: "The hack.local receipt was not released; nothing changed.", + }; + } + if ((await deps.readReceipt()) !== opts.raw) { + return { + outcome: "refused", + reason: "inputs-changed", + detail: "The receipt changed during confirmation; nothing changed.", + }; + } + const released = stamp(receipt, { state: "released" }, deps.now()); + await deps.writeReceipt(released); + return { + outcome: "released", + receipt: released, + detail: printable( + `${why}. Hack released the receipt and left the dnsmasq configuration${kept} unchanged. Restore "${receipt.oldLine}" yourself if you still want the earlier route.` + ), + }; +} + +/** One-line human message for a rollback result; control-free. */ +export function describeHackLocalRollback( + result: HackLocalRouteRollbackResult +): string { + if (result.outcome === "refused") { + return printable( + `hack.local rollback refused (${result.reason}): ${result.detail}` + ); + } + if (result.outcome === "released") { + return printable( + `Released the hack.local repair receipt. ${result.detail}` + ); + } + return printable( + `Restored "${result.receipt.oldLine}"; the dnsmasq file hash matches the receipt.${ + result.retainedResolver + ? ` ${result.retainedResolver} was retained because its ownership is unproven.` + : "" + }${ + result.restartSkipped + ? ` dnsmasq was not restarted (${result.restartSkipped}); the restored line loads when dnsmasq next starts.` + : "" + }` + ); +} + +export type NativeDnsCommandDomain = + | { readonly kind: "custom"; readonly domain: string } + | { readonly kind: "hack-local" }; + +/** + * Select the suffix for `hack global dns preview|activate|deactivate`. + * Without --domain only a configured custom default_domain is used; a + * built-in default is a closed usage error that names the right command. + */ +export function selectNativeDnsCommandDomain(opts: { + readonly verb: "preview" | "activate" | "deactivate"; + /** Normalized --domain value, if given. */ + readonly explicit: string | undefined; + /** Normalized global default_domain (hack.local when unset). */ + readonly configuredDefault: string; +}): NativeDnsCommandDomain { + const domain = opts.explicit ?? opts.configuredDefault; + if (opts.explicit === undefined && BUILT_IN_ROOTS.has(domain)) { + throw new CliUsageError( + [ + `hack global dns ${opts.verb} needs --domain: the default domain ${domain} is a built-in root this command never claims.`, + "For a dedicated custom suffix: hack global dns preview --domain v5.hack.gy", + `To point *.${HACK_LOCAL_DOMAIN} at the native relay: ${HACK_LOCAL_REPAIR_COMMAND} (inspect first with hack global dns preview --domain ${HACK_LOCAL_DOMAIN})`, + `To undo that repair: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + ].join("\n") + ); + } + if (domain === HACK_LOCAL_DOMAIN) { + if (opts.verb === "activate") { + throw new CliUsageError( + `*.${HACK_LOCAL_DOMAIN} is a built-in root, not a scoped claim. Repair its native route with: ${HACK_LOCAL_REPAIR_COMMAND}` + ); + } + return { kind: "hack-local" }; + } + return { kind: "custom", domain }; +} diff --git a/tests/native-ca-doctor-cli.test.ts b/tests/native-ca-doctor-cli.test.ts index da2e38ce2..8d43196fd 100644 --- a/tests/native-ca-doctor-cli.test.ts +++ b/tests/native-ca-doctor-cli.test.ts @@ -25,15 +25,19 @@ test("native Doctor reports unknown owner without consulting Docker-era CA state const result = await invoke(["doctor", "--json"]); expect(result.code).toBe(0); const envelope: unknown = JSON.parse(result.stdout); - expect(envelope).toMatchObject({ - ok: true, - data: { - checks: [ - { id: "native https owner", status: "warn" }, - { id: "native root trust", status: "warn" }, - ], - }, - }); + const checks: Record[] = [ + { id: "native https owner", status: "warn" }, + { id: "native root trust", status: "warn" }, + ]; + if (process.platform === "darwin") { + // The hack.local route check refuses before reading any host DNS file. + checks.push({ + id: "native hack.local route", + status: "warn", + hackLocalRoute: { status: "refused", reason: "relay-not-verified" }, + }); + } + expect(envelope).toMatchObject({ ok: true, data: { checks } }); expect(result.stdout).not.toContain("caddy local ca"); expect(result.stdout).not.toContain("docker daemon"); }); diff --git a/tests/native-domain-dns-plan.test.ts b/tests/native-domain-dns-plan.test.ts index de08a99f1..6ae43bf73 100644 --- a/tests/native-domain-dns-plan.test.ts +++ b/tests/native-domain-dns-plan.test.ts @@ -249,6 +249,15 @@ test("refuses uninspected dnsmasq configuration sources", () => { }) ) ).toThrow("uninspected DNS files"); + for (const directive of ["servers-file", "conf-script"]) { + expect(() => + planNativeDomainDns( + options({ + mainConfig: file(MAIN_CONFIG, `${V4_CONFIG}${directive}=/tmp/x\n`), + }) + ) + ).toThrow("uninspected DNS files"); + } for (const directive of ["addn-hosts", "hostsdir"]) { expect(() => planNativeDomainDns( diff --git a/tests/native-hack-local-route-cli.test.ts b/tests/native-hack-local-route-cli.test.ts new file mode 100644 index 000000000..7d3f8e22c --- /dev/null +++ b/tests/native-hack-local-route-cli.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** Selection fails before any runtime, host DNS or privileged step. */ +async function invoke(args: string[]) { + const home = await mkdtemp(join(tmpdir(), "hack-local-route-cli-")); + roots.push(home); + const child = Bun.spawn([process.execPath, "index.ts", ...args], { + env: { + ...process.env, + HOME: home, + HACK_HOME: join(home, ".hack"), + HACK_GLOBAL_CONFIG_PATH: join(home, ".hack", "hack.config.json"), + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: "/nonexistent/hack-native", + HACK_NATIVE_HOME: "/nonexistent/native-home", + HACK_NO_INTERACTIVE: "1", + }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { output: stdout + stderr, code }; +} + +for (const verb of ["preview", "activate", "deactivate"]) { + test(`global dns ${verb} without --domain names the right command`, async () => { + const result = await invoke(["global", "dns", verb]); + expect(result.code).toBe(1); + expect(result.output).toContain( + `hack global dns ${verb} needs --domain: the default domain hack.local is a built-in root` + ); + expect(result.output).toContain("hack doctor --fix"); + expect(result.output).toContain( + "hack global dns deactivate --domain hack.local" + ); + expect(result.output).not.toContain("Native domain DNS plan refused"); + }); +} + +test("global dns activate --domain hack.local points to the doctor repair", async () => { + const result = await invoke([ + "global", + "dns", + "activate", + "--domain", + "hack.local", + ]); + expect(result.code).toBe(1); + expect(result.output).toContain( + "Repair its native route with: hack doctor --fix" + ); +}); diff --git a/tests/native-hack-local-route.test.ts b/tests/native-hack-local-route.test.ts new file mode 100644 index 000000000..634510591 --- /dev/null +++ b/tests/native-hack-local-route.test.ts @@ -0,0 +1,1336 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + link, + mkdir, + mkdtemp, + open, + readdir, + readFile, + rm, + stat, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { CliUsageError } from "../src/cli/command.ts"; +import type { NativeDnsCommandRunner } from "../src/lib/native-domain-dns-host.ts"; +import { + analyzeHackLocalDnsmasq, + describeHackLocalRollback, + describeHackLocalRoute, + HACK_LOCAL_REPAIR_COMMAND, + HACK_LOCAL_RESOLVER_CONTENT, + HACK_LOCAL_ROLLBACK_COMMAND, + type HackLocalRouteDependencies, + type HackLocalRoutePlan, + type HackLocalRouteReceipt, + inspectHackLocalRoute, + parseHackLocalRouteReceipt, + repairHackLocalRoute, + replaceManagedLine, + reportHackLocalRoute, + rollbackHackLocalRoute, + selectNativeDnsCommandDomain, + sha256, +} from "../src/lib/native-hack-local-route.ts"; +import { createHackLocalRouteFileDependencies } from "../src/lib/native-hack-local-route-host.ts"; + +const ORIGINAL = [ + "# Managed by hack", + "port=53", + "listen-address=127.0.0.1", + "address=/.hack/172.30.0.2", + "address=/.hack.local/172.30.0.2", + "address=/.hack.gy/172.30.0.2", + "", + "# trailing user comment", + "", +].join("\n"); + +/** Paths for pure analysis calls only; nothing reads or writes them. */ +const MAIN = "/brew/etc/dnsmasq.conf"; +const INCLUDE_DIR = "/brew/etc/dnsmasq.d"; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +interface Calls { + readonly authorize: string[]; + readonly confirm: string[]; + restart: number; + flush: number; + verify: number; + readDnsmasq: number; + probes: string[]; +} + +/** Emulates the privileged resolver script inside the temp root only. */ +function fakePrivileged(resolverDir: string): NativeDnsCommandRunner { + return async (command) => { + expect(command.slice(0, 3)).toEqual(["sudo", "-n", "/usr/bin/python3"]); + const [action, path, content, identity] = command.slice(7); + if (!(path?.startsWith(`${resolverDir}/`) && content !== undefined)) { + return { exitCode: 1, stdout: "", stderr: "outside root" }; + } + if (action === "create") { + const handle = await open(path, "wx", 0o644); + await handle.writeFile(content); + const details = await handle.stat(); + await handle.close(); + return { + exitCode: 0, + stdout: `${details.dev}:${details.ino}\n`, + stderr: "", + }; + } + try { + const details = await stat(path); + const actual = await readFile(path, "utf8"); + if (`${details.dev}:${details.ino}` !== identity || actual !== content) { + return { exitCode: 0, stdout: "0\n", stderr: "" }; + } + await unlink(path); + return { exitCode: 0, stdout: "1\n", stderr: "" }; + } catch { + return { exitCode: 0, stdout: "0\n", stderr: "" }; + } + }; +} + +async function fixture( + opts: { + readonly dnsmasq?: string | null; + readonly resolver?: string | null; + readonly includeFiles?: Readonly>; + readonly relay?: { readonly port: number } | null; + readonly oldTargetLive?: boolean; + readonly prompt?: boolean; + readonly approve?: boolean; + readonly ownerUid?: number; + readonly overrides?: Partial; + } = {} +) { + const root = await mkdtemp(join(tmpdir(), "hack-local-route-")); + roots.push(root); + const etc = join(root, "etc"); + const includeDir = join(etc, "dnsmasq.d"); + const resolverDir = join(root, "resolver"); + const home = join(root, "native-home"); + await Promise.all([ + mkdir(includeDir, { recursive: true }), + mkdir(resolverDir), + mkdir(home, { mode: 0o700 }), + ]); + const dnsmasqPath = join(etc, "dnsmasq.conf"); + const content = opts.dnsmasq === undefined ? ORIGINAL : opts.dnsmasq; + if (content !== null) { + await writeFile(dnsmasqPath, content, { mode: 0o644 }); + await chmod(dnsmasqPath, 0o644); + } + for (const [name, text] of Object.entries(opts.includeFiles ?? {})) { + await writeFile(join(includeDir, name), text); + } + // Sibling roots that must never change. + await writeFile(join(resolverDir, "hack"), "nameserver 127.0.0.1\n"); + await writeFile(join(resolverDir, "hack.gy"), "nameserver 127.0.0.1\n"); + const resolverPath = join(resolverDir, "hack.local"); + if (opts.resolver !== undefined && opts.resolver !== null) { + await writeFile(resolverPath, opts.resolver); + } + const calls: Calls = { + authorize: [], + confirm: [], + restart: 0, + flush: 0, + verify: 0, + readDnsmasq: 0, + probes: [], + }; + const files = createHackLocalRouteFileDependencies({ + dnsmasqPath, + includeDir, + resolverPath, + receiptPath: join(home, "native-dns", "hack-local-route.json"), + runPrivileged: fakePrivileged(resolverDir), + ...(opts.ownerUid === undefined ? {} : { ownerUid: opts.ownerUid }), + }); + let clock = Date.parse("2026-10-12T12:00:00.000Z"); + const deps: HackLocalRouteDependencies = { + ...files, + readDnsmasq: async () => { + calls.readDnsmasq += 1; + return await files.readDnsmasq(); + }, + verifyRelay: async () => + opts.relay === undefined ? { port: 8443 } : opts.relay, + probeTarget: async (target) => { + calls.probes.push(target); + return opts.oldTargetLive === true; + }, + canPrompt: () => opts.prompt ?? true, + authorize: async (message) => { + calls.authorize.push(message); + return opts.approve ?? true; + }, + confirm: async (message) => { + calls.confirm.push(message); + return opts.approve ?? true; + }, + prepareRestart: async () => async () => { + calls.restart += 1; + }, + flushDnsCache: async () => { + calls.flush += 1; + }, + verifyLiveDns: async () => { + calls.verify += 1; + }, + now: () => { + clock += 1000; + return new Date(clock); + }, + ...opts.overrides, + }; + return { root, deps, calls, dnsmasqPath, resolverPath, resolverDir, home }; +} + +/** Every file under root except the given paths, as exact bytes. */ +async function snapshotTree( + root: string, + except: readonly string[] +): Promise> { + const out: Record = {}; + const walk = async (directory: string) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else if (!except.includes(path)) { + out[relative(root, path)] = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + } + } + }; + await walk(root); + return out; +} + +async function readReceipt( + deps: HackLocalRouteDependencies +): Promise { + return parseHackLocalRouteReceipt( + await readFile(deps.paths.receiptPath, "utf8") + ); +} + +/** The receipt a crash right after its publication leaves behind. */ +function pendingReceipt( + f: { readonly dnsmasqPath: string; readonly resolverPath: string }, + plan: HackLocalRoutePlan +): HackLocalRouteReceipt { + return { + version: 1, + kind: "hack-local-route-repair", + state: "pending", + domain: "hack.local", + dnsmasqPath: f.dnsmasqPath, + resolverPath: f.resolverPath, + oldLine: plan.oldLine, + newLine: plan.newLine, + beforeSha256: plan.beforeSha256, + afterSha256: plan.afterSha256, + resolverAction: "keep", + resolverSha256: sha256(HACK_LOCAL_RESOLVER_CONTENT), + resolverIdentity: null, + relayPort: 8443, + createdAt: "2026-10-12T12:00:00.000Z", + updatedAt: "2026-10-12T12:00:00.000Z", + }; +} + +async function editReceipt( + deps: HackLocalRouteDependencies, + patch: Record +): Promise { + const value: unknown = JSON.parse( + await readFile(deps.paths.receiptPath, "utf8") + ); + await writeFile( + deps.paths.receiptPath, + JSON.stringify({ ...(value as object), ...patch }) + ); +} + +const REWRITTEN = ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/127.0.0.1" +); + +test("happy path rewrites only the managed line in place and records a receipt", async () => { + const f = await fixture(); + const others = await snapshotTree(f.root, [ + f.dnsmasqPath, + f.resolverPath, + f.deps.paths.receiptPath, + ]); + + const before = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(before).toMatchObject({ + status: "repairable", + oldLine: "address=/.hack.local/172.30.0.2", + newLine: "address=/.hack.local/127.0.0.1", + resolverAction: "create", + relayPort: 8443, + command: HACK_LOCAL_REPAIR_COMMAND, + }); + + const result = await repairHackLocalRoute(f.deps); + expect(result.outcome).toBe("repaired"); + + const after = await readFile(f.dnsmasqPath, "utf8"); + expect(after).toBe(REWRITTEN); + expect(after.match(/hack\.local/g)).toHaveLength(1); + expect(after).toContain("address=/.hack/172.30.0.2\n"); + expect(after).toContain("address=/.hack.gy/172.30.0.2\n"); + expect((await stat(f.dnsmasqPath)).mode & 0o777).toBe(0o644); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect( + await snapshotTree(f.root, [ + f.dnsmasqPath, + f.resolverPath, + f.deps.paths.receiptPath, + ]) + ).toEqual(others); + expect((await readdir(join(f.root, "etc"))).sort()).toEqual([ + "dnsmasq.conf", + "dnsmasq.d", + ]); + + const receipt = await readReceipt(f.deps); + expect(receipt).toMatchObject({ + state: "applied", + oldLine: "address=/.hack.local/172.30.0.2", + newLine: "address=/.hack.local/127.0.0.1", + beforeSha256: sha256(ORIGINAL), + afterSha256: sha256(REWRITTEN), + resolverAction: "create", + relayPort: 8443, + }); + expect(receipt.resolverIdentity).toMatch(/^\d+:\d+$/); + expect(Date.parse(receipt.createdAt)).toBeGreaterThan(0); + expect((await stat(f.deps.paths.receiptPath)).mode & 0o777).toBe(0o600); + expect(f.calls.authorize).toHaveLength(1); + expect(f.calls.authorize[0]).toContain(HACK_LOCAL_ROLLBACK_COMMAND); + expect(f.calls.probes).toContain("172.30.0.2"); + expect([f.calls.restart, f.calls.flush, f.calls.verify]).toEqual([1, 1, 1]); + + const now = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(now).toMatchObject({ + status: "current", + receiptState: "applied", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("current"); +}); + +test("a live old target refuses so Docker Caddy is never broken", async () => { + const f = await fixture({ oldTargetLive: true }); + const result = await repairHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "old-target-live", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(f.calls.authorize).toHaveLength(0); + await expect(stat(f.deps.paths.receiptPath)).rejects.toThrow(); +}); + +test("unmanaged hack.local lines refuse without changes", async () => { + const variants = [ + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/172.30.0.2 # mine" + ), + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + " address=/.hack.local/172.30.0.2" + ), + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/172.30.0.2\naddress=/.hack.local/127.0.0.1" + ), + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "server=/hack.local/10.0.0.53" + ), + ]; + for (const dnsmasq of variants) { + const f = await fixture({ dnsmasq }); + const result = await repairHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "unmanaged-line", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(dnsmasq); + } + const included = await fixture({ + includeFiles: { "other.conf": "address=/.api.hack.local/10.0.0.9\n" }, + }); + expect(await repairHackLocalRoute(included.deps)).toMatchObject({ + outcome: "refused", + reason: "unmanaged-line", + }); + const missing = await fixture({ + dnsmasq: ORIGINAL.replace("address=/.hack.local/172.30.0.2\n", ""), + }); + expect(await repairHackLocalRoute(missing.deps)).toMatchObject({ + outcome: "refused", + reason: "no-managed-line", + }); +}); + +test("a foreign target refuses", async () => { + const dnsmasq = ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/10.1.2.3" + ); + const f = await fixture({ dnsmasq }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "foreign-target", + }); + expect(f.calls.probes).toEqual([]); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(dnsmasq); + expect( + analyzeHackLocalDnsmasq({ + content: dnsmasq, + path: MAIN, + includeDir: INCLUDE_DIR, + includeFiles: [], + }) + ).toMatchObject({ kind: "refused", reason: "foreign-target" }); +}); + +test("a foreign resolver refuses", async () => { + const f = await fixture({ resolver: "nameserver 10.0.0.53\n" }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "foreign-resolver", + }); + expect(await readFile(f.resolverPath, "utf8")).toBe("nameserver 10.0.0.53\n"); +}); + +test("an unverified relay refuses before reading host DNS files", async () => { + const f = await fixture({ relay: null }); + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ + status: "refused", + reason: "relay-not-verified", + }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "relay-not-verified", + }); + expect(f.calls.readDnsmasq).toBe(0); + expect(f.calls.authorize).toHaveLength(0); +}); + +test("non-interactive and declined runs refuse with an exact command", async () => { + const quiet = await fixture({ prompt: false }); + expect(await repairHackLocalRoute(quiet.deps)).toEqual({ + outcome: "refused", + reason: "non-interactive", + detail: expect.stringContaining(HACK_LOCAL_REPAIR_COMMAND), + command: HACK_LOCAL_REPAIR_COMMAND, + }); + const declined = await fixture({ approve: false }); + expect(await repairHackLocalRoute(declined.deps)).toMatchObject({ + outcome: "refused", + reason: "declined", + }); + for (const f of [quiet, declined]) { + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(f.deps.paths.receiptPath)).rejects.toThrow(); + } +}); + +test("a change during authorization refuses before any effect", async () => { + const f = await fixture(); + const deps: HackLocalRouteDependencies = { + ...f.deps, + authorize: async () => { + await writeFile(f.dnsmasqPath, `${ORIGINAL}# edited\n`); + return true; + }, + }; + expect(await repairHackLocalRoute(deps)).toMatchObject({ + outcome: "refused", + reason: "inputs-changed", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(`${ORIGINAL}# edited\n`); + await expect(stat(f.deps.paths.receiptPath)).rejects.toThrow(); +}); + +test("rollback restores the exact earlier bytes and removes the created resolver", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "rolled-back", + retainedResolver: null, + }); + const restored = await readFile(f.dnsmasqPath); + expect(restored.toString("utf8")).toBe(ORIGINAL); + expect(sha256(restored.toString("utf8"))).toBe(sha256(ORIGINAL)); + await expect(stat(f.resolverPath)).rejects.toThrow(); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); + expect(f.calls.restart).toBe(2); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "no-receipt", + }); +}); + +test("rollback of a changed file never writes it and releases the receipt on confirmation", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const edited = `${REWRITTEN}address=/.example.test/127.0.0.1\n`; + await writeFile(f.dnsmasqPath, edited); + const restarts = f.calls.restart; + + const declined = await rollbackHackLocalRoute({ + ...f.deps, + confirm: async () => false, + }); + expect(declined).toMatchObject({ outcome: "refused", reason: "declined" }); + expect((await readReceipt(f.deps)).state).toBe("applied"); + + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ outcome: "released" }); + expect(describeHackLocalRollback(result)).toContain( + "address=/.hack.local/172.30.0.2" + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); + // The resolver the repair created is kept; another tool may rely on it. + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect((await readReceipt(f.deps)).state).toBe("released"); + expect(f.calls.authorize).toHaveLength(1); + expect(f.calls.confirm).toHaveLength(1); + expect(f.calls.restart).toBe(restarts); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "no-receipt", + }); +}); + +test("an interrupted repair leaves a pending receipt that rollback recovers", async () => { + // Simulate a crash after the atomic rewrite: pending receipt plus new bytes. + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const plan = await inspectHackLocalRoute(f.deps); + if (plan.status !== "repairable") { + throw new Error("expected a repairable plan"); + } + await f.deps.writeReceipt(pendingReceipt(f, plan.plan)); + await f.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }); + + expect( + reportHackLocalRoute(await inspectHackLocalRoute(f.deps)) + ).toMatchObject({ + status: "refused", + reason: "pending-receipt", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "pending-receipt", + }); + + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("rolled-back"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); +}); + +test("a repair that fails before writing reports no change and skips the restart", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + let restarts = 0; + const failing: HackLocalRouteDependencies = { + ...f.deps, + replaceDnsmasq: async () => { + throw new Error("simulated interruption"); + }, + prepareRestart: async () => async () => { + restarts += 1; + throw new Error("restart unavailable"); + }, + }; + await expect(repairHackLocalRoute(failing)).rejects.toThrow( + "nothing was changed" + ); + expect(restarts).toBe(0); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); +}); + +test("an interrupted repair before its write recovers through rollback", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const plan = await inspectHackLocalRoute(f.deps); + if (plan.status !== "repairable") { + throw new Error("expected a repairable plan"); + } + await f.deps.writeReceipt(pendingReceipt(f, plan.plan)); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "rolled-back", + restartSkipped: null, + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("a failed live verification restores the earlier line automatically", async () => { + const f = await fixture({ + overrides: { + verifyLiveDns: async () => { + throw new Error("probe did not resolve"); + }, + }, + }); + await expect(repairHackLocalRoute(f.deps)).rejects.toThrow( + "the earlier dnsmasq line was restored" + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(f.resolverPath)).rejects.toThrow(); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("replacement refuses changed bytes and leaves no temporary file", async () => { + const f = await fixture(); + await expect( + f.deps.replaceDnsmasq({ expected: "something else", next: REWRITTEN }) + ).rejects.toThrow("changed before replacement"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readdir(join(f.root, "etc"))).sort()).toEqual([ + "dnsmasq.conf", + "dnsmasq.d", + ]); +}); + +test("an invalid receipt refuses rather than inferring ownership", async () => { + const f = await fixture(); + await mkdir(join(f.home, "native-dns"), { mode: 0o700 }); + await writeFile(f.deps.paths.receiptPath, '{"version":2}'); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); +}); + +test("dns commands without --domain never target a built-in default", () => { + for (const verb of ["preview", "activate", "deactivate"] as const) { + for (const configuredDefault of ["hack.local", "hack", "hack.gy"]) { + expect(() => + selectNativeDnsCommandDomain({ + verb, + explicit: undefined, + configuredDefault, + }) + ).toThrow(CliUsageError); + } + } + expect(() => + selectNativeDnsCommandDomain({ + verb: "preview", + explicit: undefined, + configuredDefault: "hack.local", + }) + ).toThrow(HACK_LOCAL_REPAIR_COMMAND); + expect( + selectNativeDnsCommandDomain({ + verb: "activate", + explicit: undefined, + configuredDefault: "v5.hack.gy", + }) + ).toEqual({ kind: "custom", domain: "v5.hack.gy" }); + expect( + selectNativeDnsCommandDomain({ + verb: "deactivate", + explicit: "hack.local", + configuredDefault: "hack.local", + }) + ).toEqual({ kind: "hack-local" }); + expect(() => + selectNativeDnsCommandDomain({ + verb: "activate", + explicit: "hack.local", + configuredDefault: "hack.local", + }) + ).toThrow(HACK_LOCAL_REPAIR_COMMAND); +}); + +test("doctor --fix applies the repair and JSON reports the route state", async () => { + const { checkNativeHackLocalRoute, buildDoctorJsonData } = await import( + "../src/commands/doctor.ts" + ); + const progress = { start: () => undefined, stop: () => undefined }; + const reportOnly = await fixture(); + const reported = await checkNativeHackLocalRoute({ + progress, + fix: false, + dependencies: async () => reportOnly.deps, + }); + expect(buildDoctorJsonData({ results: reported }).checks).toEqual([ + expect.objectContaining({ + id: "native hack.local route", + status: "warn", + hackLocalRoute: expect.objectContaining({ + status: "repairable", + oldLine: "address=/.hack.local/172.30.0.2", + command: HACK_LOCAL_REPAIR_COMMAND, + }), + }), + ]); + expect(await readFile(reportOnly.dnsmasqPath, "utf8")).toBe(ORIGINAL); + + const fixed = await fixture(); + const results = await checkNativeHackLocalRoute({ + progress, + fix: true, + dependencies: async () => fixed.deps, + }); + expect(results.map((result) => [result.name, result.status])).toEqual([ + ["native hack.local repair", "ok"], + ["native hack.local route", "ok"], + ]); + expect(results[0]?.message).toContain(HACK_LOCAL_ROLLBACK_COMMAND); + expect(await readFile(fixed.dnsmasqPath, "utf8")).toBe(REWRITTEN); + + const quiet = await fixture({ prompt: false }); + const refusedResults = await checkNativeHackLocalRoute({ + progress, + fix: true, + dependencies: async () => quiet.deps, + }); + expect(refusedResults[0]).toMatchObject({ + name: "native hack.local repair", + status: "warn", + }); + expect(refusedResults[0]?.message).toContain("non-interactive"); + expect(refusedResults[0]?.message).toContain(HACK_LOCAL_REPAIR_COMMAND); +}); + +test("a pending receipt whose file was rewritten since is released, never stuck", async () => { + // A crash after the rewrite, then a Docker-era doctor --fix rewrote the + // roots: the recorded hashes no longer describe the file. + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const plan = await inspectHackLocalRoute(f.deps); + if (plan.status !== "repairable") { + throw new Error("expected a repairable plan"); + } + await f.deps.writeReceipt(pendingReceipt(f, plan.plan)); + const rewritten = ORIGINAL.replaceAll("172.30.0.2", "127.0.0.1"); + await writeFile(f.dnsmasqPath, rewritten); + + const blocked = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(blocked).toMatchObject({ + status: "refused", + reason: "pending-receipt", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect(blocked.detail).toContain("release the receipt"); + + const quiet = await rollbackHackLocalRoute({ + ...f.deps, + canPrompt: () => false, + }); + expect(quiet).toMatchObject({ + outcome: "refused", + reason: "non-interactive", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect((await readReceipt(f.deps)).state).toBe("pending"); + + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "released", + }); + expect(f.calls.authorize).toHaveLength(0); + expect(f.calls.restart).toBe(0); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(rewritten); + expect( + reportHackLocalRoute(await inspectHackLocalRoute(f.deps)) + ).toMatchObject({ status: "current", receiptState: "released" }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "current", + }); +}); + +test("a BOM-prefixed file keeps its exact bytes and receipt hashes match the raw file", async () => { + const bom = Buffer.from([0xef, 0xbb, 0xbf]); + const originalBytes = Buffer.concat([bom, Buffer.from(ORIGINAL, "utf8")]); + const f = await fixture(); + await writeFile(f.dnsmasqPath, originalBytes); + const rawSha = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const repairedBytes = await readFile(f.dnsmasqPath); + expect( + Buffer.compare( + repairedBytes, + Buffer.concat([bom, Buffer.from(REWRITTEN, "utf8")]) + ) + ).toBe(0); + const receipt = await readReceipt(f.deps); + expect(receipt.beforeSha256).toBe(rawSha(originalBytes)); + expect(receipt.afterSha256).toBe(rawSha(repairedBytes)); + + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("rolled-back"); + expect(Buffer.compare(await readFile(f.dnsmasqPath), originalBytes)).toBe(0); +}); + +test("rollback without a terminal refuses with the exact command and changes nothing", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const receiptBefore = await readFile(f.deps.paths.receiptPath, "utf8"); + expect( + await rollbackHackLocalRoute({ ...f.deps, canPrompt: () => false }) + ).toEqual({ + outcome: "refused", + reason: "non-interactive", + detail: expect.stringContaining(HACK_LOCAL_ROLLBACK_COMMAND), + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + expect(await readFile(f.deps.paths.receiptPath, "utf8")).toBe(receiptBefore); + expect(f.calls.authorize).toHaveLength(1); + + const declined = await rollbackHackLocalRoute({ + ...f.deps, + authorize: async () => false, + }); + expect(declined).toMatchObject({ outcome: "refused", reason: "declined" }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); +}); + +test("rollback rechecks the file and receipt after authorization", async () => { + const edited = `${REWRITTEN}# edited during the prompt\n`; + const fileChanged = await fixture(); + expect((await repairHackLocalRoute(fileChanged.deps)).outcome).toBe( + "repaired" + ); + expect( + await rollbackHackLocalRoute({ + ...fileChanged.deps, + authorize: async () => { + await writeFile(fileChanged.dnsmasqPath, edited); + return true; + }, + }) + ).toMatchObject({ outcome: "refused", reason: "inputs-changed" }); + expect(await readFile(fileChanged.dnsmasqPath, "utf8")).toBe(edited); + expect((await readReceipt(fileChanged.deps)).state).toBe("applied"); + + const receiptChanged = await fixture(); + expect((await repairHackLocalRoute(receiptChanged.deps)).outcome).toBe( + "repaired" + ); + expect( + await rollbackHackLocalRoute({ + ...receiptChanged.deps, + authorize: async () => { + await editReceipt(receiptChanged.deps, {}); + return true; + }, + }) + ).toMatchObject({ outcome: "refused", reason: "inputs-changed" }); + expect(await readFile(receiptChanged.dnsmasqPath, "utf8")).toBe(REWRITTEN); +}); + +test("a receipt whose line does not reproduce its hashes is never written", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await editReceipt(f.deps, { oldLine: "address=/.hack.local/::1" }); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + expect(f.calls.authorize).toHaveLength(1); +}); + +test("a receipt line must be a single managed line", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const injected = "address=/.hack.local/172.30.0.2\nserver=8.8.8.8"; + for (const patch of [ + // Hashes edited to match, so only the line format check can refuse. + { + oldLine: injected, + beforeSha256: sha256( + REWRITTEN.replace("address=/.hack.local/127.0.0.1", injected) + ), + }, + { oldLine: "address=/.hack.local/10.0.0.9" }, + { dnsmasqPath: `${f.dnsmasqPath}\u001b[2J` }, + { dnsmasqPath: "relative/dnsmasq.conf" }, + { resolverIdentity: "not-an-identity" }, + ]) { + const raw = await readFile(f.deps.paths.receiptPath, "utf8"); + await editReceipt(f.deps, patch); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + await writeFile(f.deps.paths.receiptPath, raw); + } +}); + +test("a receipt for another dnsmasq path is released without writing the current file", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await editReceipt(f.deps, { dnsmasqPath: "/elsewhere/etc/dnsmasq.conf" }); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ outcome: "released" }); + expect(f.calls.confirm[0]).toContain("/elsewhere/etc/dnsmasq.conf"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); +}); + +test("a release rechecks the receipt after confirmation", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await writeFile(f.dnsmasqPath, `${REWRITTEN}# edited\n`); + expect( + await rollbackHackLocalRoute({ + ...f.deps, + confirm: async () => { + await editReceipt(f.deps, {}); + return true; + }, + }) + ).toMatchObject({ outcome: "refused", reason: "inputs-changed" }); + expect((await readReceipt(f.deps)).state).toBe("applied"); +}); + +test("a failed restart is retried during cleanup so dnsmasq is not left stopped", async () => { + // Resolver-only repair: the line already targets the relay. + const f = await fixture({ dnsmasq: REWRITTEN }); + let restarts = 0; + await expect( + repairHackLocalRoute({ + ...f.deps, + // Nothing for the restore itself to change: only the failed restart + // makes a second restart necessary. + removeResolverIfOwned: async () => false, + prepareRestart: async () => async () => { + restarts += 1; + if (restarts === 1) { + throw new Error("bootstrap failed"); + } + }, + }) + ).rejects.toThrow("was retained because its ownership is unproven"); + expect(restarts).toBe(2); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("automatic cleanup never overwrites a concurrent edit and keeps the receipt pending", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const edited = `${REWRITTEN}# concurrent edit\n`; + await expect( + repairHackLocalRoute({ + ...f.deps, + verifyLiveDns: async () => { + await writeFile(f.dnsmasqPath, edited); + throw new Error("probe did not resolve"); + }, + }) + ).rejects.toThrow("automatic restore failed"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); + expect((await readReceipt(f.deps)).state).toBe("pending"); + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("released"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); +}); + +test("an unavailable dnsmasq service refuses repair and does not block rollback", async () => { + const unavailable = async (): Promise<() => Promise> => { + throw new Error("Native DNS activation requires exactly one dnsmasq"); + }; + const repair = await fixture(); + expect( + await repairHackLocalRoute({ ...repair.deps, prepareRestart: unavailable }) + ).toMatchObject({ outcome: "refused", reason: "restart-unavailable" }); + expect(await readFile(repair.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(repair.deps.paths.receiptPath)).rejects.toThrow(); + + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const result = await rollbackHackLocalRoute({ + ...f.deps, + prepareRestart: unavailable, + }); + expect(result).toMatchObject({ + outcome: "rolled-back", + restartSkipped: expect.stringContaining("exactly one dnsmasq"), + }); + expect(describeHackLocalRollback(result)).toContain( + "dnsmasq was not restarted" + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(f.resolverPath)).rejects.toThrow(); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("repair and rollback refuse while another run holds the lock", async () => { + const f = await fixture(); + const release = await f.deps.acquireLock(); + if (!release) { + throw new Error("expected the lock"); + } + expect(await f.deps.acquireLock()).toBeNull(); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "busy", + }); + expect(f.calls.authorize).toHaveLength(0); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await release(); + + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const held = await f.deps.acquireLock(); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "busy", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + await held?.(); + expect( + (await readdir(join(f.home, "native-dns"))).filter((name) => + name.includes("lock") + ) + ).toEqual([]); +}); + +test("a lock left by a process that no longer exists is taken over", async () => { + const f = await fixture(); + await mkdir(join(f.home, "native-dns"), { mode: 0o700 }); + await writeFile(`${f.deps.paths.receiptPath}.lock`, "2147483646\n"); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await expect(stat(`${f.deps.paths.receiptPath}.lock`)).rejects.toThrow(); +}); + +test("dnsmasq replacement refuses a linked or foreign-owned file", async () => { + const linked = await fixture(); + await link(linked.dnsmasqPath, join(linked.root, "dnsmasq.link")); + await expect( + linked.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }) + ).rejects.toThrow("not a regular file owned by the current user"); + expect(await readFile(linked.dnsmasqPath, "utf8")).toBe(ORIGINAL); + + const uid = process.getuid?.() ?? 0; + const foreign = await fixture({ ownerUid: uid + 1 }); + await expect( + foreign.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }) + ).rejects.toThrow("not a regular file owned by the current user"); + expect(await readFile(foreign.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readdir(join(foreign.root, "etc"))).sort()).toEqual([ + "dnsmasq.conf", + "dnsmasq.d", + ]); +}); + +test("the receipt directory must be private and owned by the current user", async () => { + const shared = await fixture(); + await mkdir(join(shared.home, "native-dns"), { mode: 0o700 }); + await chmod(join(shared.home, "native-dns"), 0o755); + await expect(repairHackLocalRoute(shared.deps)).rejects.toThrow( + "receipt directory is not private" + ); + expect(await readFile(shared.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(shared.calls.authorize).toHaveLength(0); + + const uid = process.getuid?.() ?? 0; + const foreign = await fixture({ ownerUid: uid + 1 }); + await expect(foreign.deps.acquireLock()).rejects.toThrow( + "receipt directory is not private" + ); +}); + +test("diagnostics never echo raw line contents or control characters", async () => { + const hostile = ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/\u001b[31mSECRET-VALUE" + ); + const f = await fixture({ dnsmasq: hostile }); + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ status: "refused", reason: "unmanaged-line" }); + expect(report.detail).toBe( + "Line 5 claims .hack.local but is not in the Hack-managed address=/.hack.local/
form." + ); + const message = describeHackLocalRoute(report); + expect(message).not.toContain("\u001b"); + expect(message).not.toContain("SECRET-VALUE"); + + const foreign = analyzeHackLocalDnsmasq({ + content: ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/10.1.2.3" + ), + path: MAIN, + includeDir: INCLUDE_DIR, + includeFiles: [], + }); + expect(foreign).toMatchObject({ kind: "refused", reason: "foreign-target" }); + expect(JSON.stringify(foreign)).not.toContain("10.1.2.3"); + + const included = analyzeHackLocalDnsmasq({ + content: ORIGINAL, + path: MAIN, + includeDir: INCLUDE_DIR, + includeFiles: [ + { + path: "/x/\u001b]0;title\u0007.conf", + content: "address=/.hack.local/1.2.3.4\n", + }, + ], + }); + expect(JSON.stringify(included)).not.toContain("\\u001b"); + expect(JSON.stringify(included)).not.toContain("1.2.3.4"); +}); + +test("a main configuration that loads an uninspected file refuses and changes nothing", async () => { + const f = await fixture(); + const other = join(f.root, "other.conf"); + await writeFile(other, "address=/.hack.local/10.9.9.9\n"); + await writeFile(f.dnsmasqPath, `${ORIGINAL}conf-file=${other}\n`); + const before = await snapshotTree(f.root, []); + + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ + status: "refused", + reason: "uninspected-config-source", + }); + expect(report.detail).toBe( + `${f.dnsmasqPath} line 9 uses conf-file, which loads DNS data Hack did not inspect; refusing to change the hack.local route.` + ); + expect(report.detail).not.toContain("other.conf"); + expect(report.detail).not.toContain("10.9.9.9"); + + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "uninspected-config-source", + }); + expect(f.calls.authorize).toHaveLength(0); + expect(f.calls.probes).toEqual([]); + expect(await snapshotTree(f.root, [])).toEqual(before); +}); + +test("every uninspected dnsmasq source refuses, in the main file or an include", () => { + const analyze = ( + content: string, + includeFiles: readonly { path: string; content: string }[] = [] + ) => + analyzeHackLocalDnsmasq({ + content, + path: MAIN, + includeDir: INCLUDE_DIR, + includeFiles, + }); + const sources = [ + "conf-file=/tmp/other.conf", + "conf-file = /tmp/other.conf", + "CONF-FILE=/tmp/other.conf", + "conf-script=/tmp/emit-config", + "addn-hosts=/tmp/hosts", + "hostsdir=/tmp/hosts.d", + "servers-file=/tmp/servers", + "conf-dir=/tmp/dnsmasq.d,*.conf", + `conf-dir=${INCLUDE_DIR}`, + `conf-dir=${INCLUDE_DIR}/,*.conf`, + ]; + for (const source of sources) { + expect(analyze(`${ORIGINAL}${source}\n`)).toMatchObject({ + kind: "refused", + reason: "uninspected-config-source", + }); + const included = analyze(ORIGINAL, [ + { path: `${INCLUDE_DIR}/user.conf`, content: `${source}\n` }, + ]); + expect(included).toMatchObject({ + kind: "refused", + reason: "uninspected-config-source", + }); + expect(JSON.stringify(included)).not.toContain("/tmp/"); + } + // The inspected include directory and commented directives are allowed. + for (const allowed of [ + `conf-dir=${INCLUDE_DIR},*.conf`, + "#conf-file=/tmp/other.conf", + " # servers-file=/tmp/servers", + ]) { + expect(analyze(`${ORIGINAL}${allowed}\n`)).toMatchObject({ + kind: "line", + target: "172.30.0.2", + }); + } +}); + +test("unreadable DNS files refuse with a closed reason and no file content", async () => { + const marker = "SECRET-MARKER"; + const invalidUtf8 = Buffer.concat([ + Buffer.from(`${ORIGINAL}# ${marker} `), + Buffer.from([0xff, 0xfe, 0x0a]), + ]); + const expectRefusal = async ( + f: Awaited> + ): Promise => { + const before = await snapshotTree(f.root, []); + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ + status: "refused", + reason: "unreadable-config", + }); + const result = await repairHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "unreadable-config", + }); + for (const text of [JSON.stringify(report), JSON.stringify(result)]) { + expect(text).not.toContain(marker); + expect(text).not.toContain("10.9.9.9"); + } + expect(f.calls.authorize).toHaveLength(0); + expect(await snapshotTree(f.root, [])).toEqual(before); + }; + + const main = await fixture(); + await writeFile(main.dnsmasqPath, invalidUtf8); + await expectRefusal(main); + + const include = await fixture(); + await writeFile( + join(include.root, "etc", "dnsmasq.d", "bad.conf"), + invalidUtf8 + ); + await expectRefusal(include); + + const linked = await fixture(); + const target = join(linked.root, "elsewhere.conf"); + await writeFile(target, `# ${marker}\naddress=/.hack.local/10.9.9.9\n`); + await symlink(target, join(linked.root, "etc", "dnsmasq.d", "linked.conf")); + await expectRefusal(linked); +}); + +test("rollback refuses an unreadable dnsmasq configuration without writing", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const invalid = Buffer.concat([ + Buffer.from("# SECRET-MARKER "), + Buffer.from([0xc3, 0x28, 0x0a]), + ]); + await writeFile(f.dnsmasqPath, invalid); + const receipt = await readFile(f.deps.paths.receiptPath, "utf8"); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "unreadable-config", + }); + expect(JSON.stringify(result)).not.toContain("SECRET-MARKER"); + expect(await readFile(f.dnsmasqPath)).toEqual(invalid); + expect(await readFile(f.deps.paths.receiptPath, "utf8")).toBe(receipt); + expect(f.calls.authorize).toHaveLength(1); +}); + +test("replaceManagedLine replaces exactly one whole line or nothing", () => { + const from = "address=/.hack.local/172.30.0.2"; + const to = "address=/.hack.local/127.0.0.1"; + expect(replaceManagedLine({ content: ORIGINAL, from, to })).toBe(REWRITTEN); + expect( + replaceManagedLine({ content: ORIGINAL.replace(`${from}\n`, ""), from, to }) + ).toBeNull(); + expect( + replaceManagedLine({ + content: ORIGINAL.replace(from, `${from}\n${from}`), + from, + to, + }) + ).toBeNull(); + // A longer line that merely starts with the managed text is not a match. + expect( + replaceManagedLine({ + content: ORIGINAL.replace(from, `${from}0`), + from, + to, + }) + ).toBeNull(); +}); + +test("rollback refuses when the restored file does not hash to the receipt", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const restartsAfterRepair = f.calls.restart; + // The write reports success but lands bytes other than the receipt's. + const deps: HackLocalRouteDependencies = { + ...f.deps, + replaceDnsmasq: async ({ expected, next }) => + await f.deps.replaceDnsmasq({ expected, next: `${next}# extra\n` }), + }; + await expect(rollbackHackLocalRoute(deps)).rejects.toThrow( + "Restored dnsmasq configuration hash does not match" + ); + expect((await readReceipt(f.deps)).state).toBe("applied"); + expect(f.calls.restart).toBe(restartsAfterRepair); +}); + +test("a created resolver without a recorded identity is never removed", async () => { + const f = await fixture(); + const removals: unknown[] = []; + const deps: HackLocalRouteDependencies = { + ...f.deps, + // The resolver is created, but its identity never reaches the receipt. + createResolver: async (content) => { + await f.deps.createResolver(content); + throw new Error("identity lost"); + }, + removeResolverIfOwned: async (request) => { + removals.push(request); + return true; + }, + }; + await expect(repairHackLocalRoute(deps)).rejects.toThrow( + `${f.resolverPath} was retained because its ownership is unproven` + ); + expect(removals).toEqual([]); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(await readReceipt(f.deps)).toMatchObject({ + state: "rolled-back", + resolverAction: "create", + resolverIdentity: null, + }); +});