Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/guides/init-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <suffix>` and then the
separately confirmed `hack global dns activate --domain <suffix>` 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
Expand Down
81 changes: 77 additions & 4 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<brew>/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-home>/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
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1022,9 +1022,9 @@ hack global dns <subcommand> [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

Expand Down
108 changes: 108 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1160,13 +1188,90 @@ 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<HackLocalRouteDependencies>;
}): Promise<readonly TimedCheckResult[]> {
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;
readonly detail: string;
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;
};
Expand Down Expand Up @@ -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 }),
Expand Down
Loading
Loading