From 665f250d573804df56fa18a7444e2088c97d2e0e Mon Sep 17 00:00:00 2001 From: Lucas Teske Date: Tue, 18 Aug 2026 09:33:15 -0300 Subject: [PATCH] feat(setup): install hsh-tunneld automatically [DEP-141] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hsh now fetches the OS/arch-matched daemon, verifies it against the release SHA256SUMS, and hands it to the system service manager via sudo. Runs implicitly on first `hsh login`; `--no-setup` opts out. 🤖 Generated with Mister Maluco Co-authored-by: MisterMal --- README.md | 47 ++-- src/commands/login.ts | 62 ++++- src/commands/setup.ts | 125 +++++++++ src/index.ts | 2 + src/tunnel/installer.ts | 394 ++++++++++++++++++++++++++++ tests/login-command-options.test.ts | 4 + tests/tunnel-installer.test.ts | 351 +++++++++++++++++++++++++ 7 files changed, 964 insertions(+), 21 deletions(-) create mode 100644 src/commands/setup.ts create mode 100644 src/tunnel/installer.ts create mode 100644 tests/tunnel-installer.test.ts diff --git a/README.md b/README.md index ba28962..9c10e09 100644 --- a/README.md +++ b/README.md @@ -69,37 +69,50 @@ curl -L -o hsh.tar.gz https://github.com/hoophq/hsh/releases/latest/download/hsh curl -L -o hsh.tar.gz https://github.com/hoophq/hsh/releases/latest/download/hsh-linux-arm64.tar.gz ``` -Then extract and install: +Then extract and install the CLI: ```bash tar xzf hsh.tar.gz cd hsh-*/ -sudo install -m 0755 hsh /usr/local/bin/ # the CLI +sudo install -m 0755 hsh /usr/local/bin/ +``` -# Linux: registers hsh-tunneld as a systemd service -./install.sh +Windows: download `hsh-windows-x64.zip` (or `hsh-windows-arm64.zip`), unpack +with Explorer or `Expand-Archive`, and copy `hsh.exe` somewhere on your PATH. +The tunnel daemon has no Windows service support yet, so step 2 does not +apply — the rest of `hsh` works without it. -# macOS: registers hsh-tunneld as a LaunchDaemon -sudo ./hsh-tunneld install +### 2. Install the networking components + +```bash +hsh setup ``` -On Linux the install script registers `hsh-tunneld` as a systemd service. -On macOS run `sudo ./hsh-tunneld install` directly — it registers the -daemon as a system-wide LaunchDaemon (the `install.sh` wrapper is -currently Linux-only). Either way the installer adds you to the `hsh` -group, which gates access to the daemon's control socket; open a new -login session (or run `newgrp hsh`) for it to take effect. +This downloads the matching `hsh-tunneld` daemon for your OS and +architecture, verifies it against the release's `SHA256SUMS`, and +registers it with your system service manager (systemd on Linux, a +LaunchDaemon on macOS). `sudo` prompts for your password once. -Windows: download `hsh-windows-x64.zip` (or `hsh-windows-arm64.zip`), unpack -with Explorer or `Expand-Archive`, and copy `hsh.exe` somewhere on your PATH. +`hsh login` runs this automatically the first time, so `hsh setup` is +only needed if you want to install the daemon up front, or to reinstall +it later with `hsh setup --force`. + +The installer adds you to the `hsh` group, which gates access to the +daemon's control socket. Group membership only applies to new login +sessions, so open a new terminal (or run `newgrp hsh`) afterwards. + +Already have the release archive extracted? `sudo ./hsh-tunneld install` +does the same registration on both Linux and macOS. The bundled +`./install.sh` wrapper is a Linux-only convenience around it and refuses +to run on macOS. -### 2. Configure +### 3. Configure ```bash hsh config set api-url https://your-company.hoop.dev ``` -### 3. Authenticate +### 4. Authenticate ```bash hsh login @@ -107,7 +120,7 @@ hsh login Opens your browser for OAuth. Done. -### 4. Activate shell plugins +### 5. Activate shell plugins Add this single line to your shell profile: diff --git a/src/commands/login.ts b/src/commands/login.ts index e4d69fa..2aec4f2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,8 +1,34 @@ import { Command } from "commander"; +import { openSync, closeSync } from "fs"; import { login } from "../auth/manager.ts"; import { loginDaemon } from "./tunnel.ts"; +import { daemonInstalled, runSetup } from "./setup.ts"; +import { daemonInstallSupported } from "../tunnel/installer.ts"; import { getApiUrl } from "../config/store.ts"; -import { error } from "../ui/output.ts"; +import { error, info } from "../ui/output.ts"; + +/** + * Does this process have a controlling terminal sudo can prompt on? + * + * NOT `process.stdin.isTTY`: sudo does not read the password from + * stdin, it opens /dev/tty directly. `hsh login < file` therefore has + * a non-TTY stdin while sudo can still prompt perfectly well — gating + * on stdin would skip setup for anyone who redirects stdin, and (worse) + * would not actually protect the case it was meant to, since a real + * CI runner with no tty is exactly where opening /dev/tty fails. + * + * Probing /dev/tty is what sudo itself does, so this answers the only + * question that matters: can the user be prompted? + */ +function hasControllingTerminal(): boolean { + if (process.platform === "win32") return false; + try { + closeSync(openSync("/dev/tty", "r")); + return true; + } catch { + return false; + } +} export const loginCommand = new Command("login") .description("Authenticate with Hoop (and the tunnel daemon if installed)") @@ -15,13 +41,17 @@ export const loginCommand = new Command("login") // "negatable boolean options". .option( "--no-tunnel", - "Skip authenticating the tunnel daemon, even if it is installed" + "Skip the tunnel daemon entirely (no install, no daemon login)" ) .option( "--no-browser", "Print the login URL instead of opening the browser (tunnel daemon flow)" ) - .action(async (opts: { tunnel: boolean; browser: boolean }) => { + .option( + "--no-setup", + "Do not install the tunnel daemon when it is missing" + ) + .action(async (opts: { tunnel: boolean; browser: boolean; setup: boolean }) => { const apiUrl = getApiUrl(); if (!apiUrl) { error("API URL not configured. Run first:"); @@ -33,7 +63,31 @@ export const loginCommand = new Command("login") // This is what the kubectl/ssh plugins use. Always runs. await login(); - // Step 2: best-effort daemon login. The hsh-tunneld daemon owns its + // Step 2: install the tunnel daemon if this machine doesn't have + // it yet (DEP-141). A first `hsh login` on a fresh machine is the + // point where the user has just proven they want Hoop access, so + // it is the natural place to ask for the one sudo password the + // setup needs. `--no-setup` opts out explicitly. + // + // `daemonInstallSupported()` keeps the implicit path silent on + // hosts where setup could only ever fail (Windows, exotic arches): + // there is nothing the user could run, and hsh works fine without + // the tunnel. With no controlling terminal we print a hint instead + // of running it — sudo would block on a prompt nobody can answer, + // and a hung pipeline is worse than a missing daemon. An explicit + // `hsh setup` still runs in both cases and reports the real reason. + const setupWanted = + opts.tunnel && opts.setup && !daemonInstalled() && daemonInstallSupported(); + if (setupWanted && hasControllingTerminal()) { + console.log(); + // A failed install must not be reported as a clean login: the + // user asked for Hoop access and did not fully get it. + if (!(await runSetup())) process.exitCode = 1; + } else if (setupWanted) { + info("Tunnel daemon is not installed. Run `hsh setup` to install it."); + } + + // Step 3: best-effort daemon login. The hsh-tunneld daemon owns its // own token (it does its own gateway round-trip via IPC — we never // hand it the CLI's token, preserving the daemon-owns-token trust // model). diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..25e9946 --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,125 @@ +/** + * `hsh setup` — install the Hoop networking components (DEP-141). + * + * The setup journey a user should get after dropping `hsh` on their + * PATH is: run one command, type their password once, and have the + * tunnel daemon running as a system service. Before this command they + * had to know that a second binary existed, which archive it came in, + * and which of two platform-specific installers to run. + * + * This file owns only the user-facing flow (messages, ordering, exit + * codes). Everything mechanical — asset selection, checksum + * verification, sudo — lives in src/tunnel/installer.ts. + */ + +import { Command } from "commander"; +import { getApiUrl } from "../config/store.ts"; +import { ExitCodes } from "../plugins/exit-codes.ts"; +import { TunnelClient } from "../tunnel/ipc-client.ts"; +import { DaemonInstallError, installDaemon } from "../tunnel/installer.ts"; +import { readControlToken, resolveTokenPath } from "../tunnel/socket-path.ts"; +import { debug } from "../ui/log.ts"; +import { dim, error, info, success, warn } from "../ui/output.ts"; + +/** + * The banner DEP-141 specifies verbatim. Kept as an exported constant + * because both `hsh setup` and the implicit setup leg of `hsh login` + * must print the same line — a user should not be able to tell from + * the output which entry point ran. + */ +export const SETUP_BANNER = "Installing Hoop Networking Components"; + +/** + * Is the tunnel daemon already installed on this machine? + * + * Same probe `hsh login` uses (a readable — or merely present — + * control token proves the daemon or its installer ran here), so the + * two commands can never disagree about whether setup is needed. + */ +export function daemonInstalled(): boolean { + if (readControlToken()) return true; + const tok = resolveTokenPath(); + return tok.exists || tok.fromEnv; +} + +/** + * Download + install the daemon, printing progress. Returns false when + * the install failed (the caller sets the exit code); the error and its + * remediation hint have already been rendered. + */ +export async function runSetup(opts: { force?: boolean } = {}): Promise { + if (!opts.force && daemonInstalled()) { + success("Hoop networking components are already installed."); + info("Reinstall or upgrade them with: hsh setup --force"); + return true; + } + + info(SETUP_BANNER); + + try { + const result = await installDaemon({ onProgress: (m) => dim(` ${m}`) }); + success(`Installed hsh-tunneld ${result.version}.`); + } catch (err) { + if (err instanceof DaemonInstallError) { + error(err.message); + if (err.hint) info(err.hint); + return false; + } + error(`Setup failed: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + + // Hand the daemon the gateway URL the CLI already knows, so the user + // does not have to configure the same value twice. Best-effort: the + // installer just added the user to the `hsh` group, and OS group + // membership only applies to NEW login sessions, so in this shell the + // control token is usually still unreadable. That is expected, not a + // failure — we fall through to the "open a new terminal" hint. + const apiUrl = getApiUrl(); + let daemonConfigured = false; + if (apiUrl) { + try { + const client = TunnelClient.connect(); + const cfg = await client.config(); + // A daemon that already points somewhere keeps pointing there: + // silently repointing an existing tunnel at a different gateway + // on a reinstall would be a surprising, hard-to-notice change. + if (!cfg.api_url) { + await client.updateConfig({ api_url: apiUrl }); + info(`Daemon gateway set to ${apiUrl}.`); + } else if (cfg.api_url !== apiUrl) { + warn(`Daemon is pointed at ${cfg.api_url}, not the CLI's ${apiUrl}.`); + info(`Change it with: hsh tunnel config set api-url ${apiUrl}`); + } else { + info(`Daemon gateway is ${cfg.api_url}.`); + } + daemonConfigured = true; + } catch (err) { + debug("setup", `daemon config skipped: ${String(err)}`); + } + } else { + warn("No gateway configured yet. Run: hsh config set api-url "); + } + + console.log(); + info("Next steps:"); + if (!daemonConfigured) { + // Group membership is the reason the CLI cannot reach the daemon + // from this shell. Say so explicitly — "permission denied on a + // socket" is the single most confusing post-install state. + dim(" 1. Open a new terminal (or run: newgrp hsh) so your new group applies."); + dim(" 2. hsh login"); + } else { + dim(" 1. hsh login"); + } + dim(" Then: hsh tunnel connections"); + return true; +} + +export const setupCommand = new Command("setup") + .description("Install the Hoop networking components (hsh-tunneld system service)") + .option("--force", "Reinstall even if the daemon is already present") + .action(async (opts: { force?: boolean }) => { + const ok = await runSetup({ force: opts.force }); + if (!ok) process.exit(ExitCodes.GenericError); + }); diff --git a/src/index.ts b/src/index.ts index f3a413e..4ec0d34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { program } from "commander"; import { loginCommand } from "./commands/login.ts"; +import { setupCommand } from "./commands/setup.ts"; import { logoutCommand } from "./commands/logout.ts"; import { statusCommand } from "./commands/status.ts"; import { configCommand } from "./commands/config.ts"; @@ -24,6 +25,7 @@ program .version(`${VERSION} (hsh-tunneld ${BUNDLED_DAEMON_VERSION})`); program.addCommand(loginCommand); +program.addCommand(setupCommand); program.addCommand(logoutCommand); program.addCommand(statusCommand); program.addCommand(configCommand); diff --git a/src/tunnel/installer.ts b/src/tunnel/installer.ts new file mode 100644 index 0000000..f2a380c --- /dev/null +++ b/src/tunnel/installer.ts @@ -0,0 +1,394 @@ +/** + * Runtime acquisition + privileged installation of the `hsh-tunneld` + * daemon (DEP-141). + * + * # Why this exists + * + * Until now the daemon only reached a user's machine through the + * release tarball: extract, then run `./install.sh` (Linux) or + * `sudo ./hsh-tunneld install` (macOS) by hand. Anyone who installed + * `hsh` any other way (brew, nix, a bare binary copied onto PATH) got + * a CLI that silently skipped every tunnel feature, because + * `loginDaemon()` treats an absent daemon as "not installed, say + * nothing". This module closes that gap: `hsh` fetches the daemon for + * the running OS/arch itself and hands it to the OS service manager. + * + * # Trust model + * + * The artifact we download is executed **as root**. Verification is + * therefore mandatory, not best-effort: + * + * 1. The version is the one this build was stamped against + * (BUNDLED_DAEMON_VERSION), so `hsh` and `hsh-tunneld` stay a + * matched pair — the same guarantee the tarball gives. + * 2. `SHA256SUMS` from that release is the trust root. A missing + * manifest, a missing entry, or a hash mismatch aborts the + * install. We never fall back to "install unverified". + * + * This mirrors the build-time policy in scripts/lib/daemon-download.ts; + * the difference is only *when* the download happens (user's machine at + * setup time vs. CI at release time). + * + * # Privilege escalation + * + * We do NOT re-implement service registration. `hsh-tunneld install` + * already writes the systemd unit / LaunchDaemon plist, creates the + * `hsh` group, adds the invoking user to it, and starts the service — + * with test coverage on the Go side. We shell out to + * `sudo install` with inherited stdio so sudo itself + * owns the password prompt (never hsh: we must never read, buffer, or + * relay the user's password). + * + * `SUDO_USER` is what the daemon's installer reads to decide which + * human to add to the `hsh` group, and sudo sets it for us. + */ + +import { spawnSync } from "child_process"; +import { chmodSync, existsSync, mkdirSync, unlinkSync } from "fs"; +import { join } from "path"; +import { ApiUnreachableError, fetchWithTimeout } from "../api/client.ts"; +import { getHshDir } from "../config/store.ts"; +import { BUNDLED_DAEMON_VERSION } from "../daemon-version-stamp.ts"; +import { downloadAndInstall } from "../update/install.ts"; +import { parseChecksumsFile } from "../update/releases.ts"; +import { debug } from "../ui/log.ts"; + +/** + * Release repo that publishes the daemon. Hard-coded for the same + * reason scripts/lib/daemon-download.ts hard-codes it: a binary we run + * as root needs an explicit, auditable trust root, not a configurable + * one. + */ +const RELEASE_REPO = "hoophq/hoop"; + +/** + * Download host. Release assets are served from github.com (the API + * host only hands out metadata). + * + * `HSH_DAEMON_RELEASE_BASE` overrides it, mirroring the existing + * `HSH_GITHUB_API` escape hatch in src/update/releases.ts. It exists so + * tests can point the flow at a local stub and so a fork behind a + * corporate mirror has one knob instead of a patch. It does not weaken + * the trust model: the checksum manifest is fetched from the same base, + * so anyone able to set this variable could already run arbitrary code + * as the user. + */ +function downloadBase(): string { + const override = process.env.HSH_DAEMON_RELEASE_BASE; + if (override && override.trim() !== "") { + return override.trim().replace(/\/+$/, ""); + } + return "https://github.com"; +} + +/** Manifest that every daemon release attaches. */ +const SHA256SUMS_NAME = "SHA256SUMS"; + +/** The manifest is ~1 KB; it should never need the download budget. */ +const MANIFEST_TIMEOUT_MS = 15_000; + +/** + * Platforms the daemon publishes binaries for, keyed the way Go names + * them — the release filename is `hsh-tunneld--[.exe]`. + */ +export interface DaemonTarget { + goos: "linux" | "darwin" | "windows"; + goarch: "amd64" | "arm64"; +} + +/** + * Map Bun's `process.platform`/`process.arch` onto the daemon's Go + * naming. Returns null for a combination the daemon does not publish, + * so the caller can print a precise "no daemon for your platform" + * rather than 404-ing halfway through a download. + * + * Pure (takes its inputs) so tests can pin every combination without + * touching process state. + */ +export function daemonTargetFor( + platform: NodeJS.Platform, + arch: NodeJS.Architecture, +): DaemonTarget | null { + const goarch = arch === "x64" ? "amd64" : arch === "arm64" ? "arm64" : null; + if (!goarch) return null; + switch (platform) { + case "linux": + return { goos: "linux", goarch }; + case "darwin": + return { goos: "darwin", goarch }; + case "win32": + return { goos: "windows", goarch }; + default: + return null; + } +} + +/** + * Can this host actually complete `hsh setup`? + * + * False for architectures the daemon publishes no binary for, and for + * Windows — hsh-tunneld's Windows service backend is still a stub that + * returns ErrUnsupportedPlatform, so `install` would download 18 MB and + * then refuse. + * + * Exported so callers that run setup *implicitly* (the daemon leg of + * `hsh login`) can stay silent on hosts where it could only ever fail, + * while an explicit `hsh setup` still gets the specific error from + * `installDaemon` explaining why. + */ +export function daemonInstallSupported( + platform: NodeJS.Platform = process.platform, + arch: NodeJS.Architecture = process.arch, +): boolean { + const target = daemonTargetFor(platform, arch); + return target !== null && target.goos !== "windows"; +} + +/** Release filename for a target. Mirrors `daemonAssetName` in scripts/lib. */ +export function daemonAssetName(t: DaemonTarget): string { + const suffix = t.goos === "windows" ? ".exe" : ""; + return `hsh-tunneld-${t.goos}-${t.goarch}${suffix}`; +} + +/** + * Where a downloaded daemon is staged before `hsh-tunneld install` + * copies it to its final home (/usr/local/bin/hsh-tunneld). + * + * We stage inside the hsh state dir rather than a tmpdir so a failed + * install leaves the (verified) binary somewhere the user can inspect + * and re-run manually, and so HSH_HOME keeps tests hermetic. + */ +export function stagedDaemonPath(t: DaemonTarget): string { + const dir = join(getHshDir(), "bin"); + mkdirSync(dir, { recursive: true }); + return join(dir, t.goos === "windows" ? "hsh-tunneld.exe" : "hsh-tunneld"); +} + +/** + * Thrown for every failure mode of this module. `hint` carries the one + * concrete next step the user can take, so callers render a consistent + * two-line error instead of inventing their own remediation text. + */ +export class DaemonInstallError extends Error { + readonly hint: string | undefined; + + constructor(message: string, hint?: string) { + super(message); + this.name = "DaemonInstallError"; + this.hint = hint; + } +} + +/** + * Fetch the SHA256 the release publishes for `assetName`. + * + * Absence is fatal, never a warning: this binary runs as root, so an + * unverifiable download must not be installed. `hsh update` can afford + * to warn-and-continue because it only replaces the unprivileged CLI + * the user is already running. + */ +export async function fetchExpectedSha256( + version: string, + assetName: string, +): Promise { + const url = `${downloadBase()}/${RELEASE_REPO}/releases/download/${version}/${SHA256SUMS_NAME}`; + debug("tunnel.install", `fetching manifest ${url}`); + const res = await fetchWithTimeout(url, { timeoutMs: MANIFEST_TIMEOUT_MS }); + if (!res.ok) { + throw new DaemonInstallError( + `Could not fetch ${SHA256SUMS_NAME} for hsh-tunneld ${version} (HTTP ${res.status}).`, + `Check ${url} is reachable, then retry.`, + ); + } + const sha = parseChecksumsFile(await res.text(), assetName); + if (!sha) { + throw new DaemonInstallError( + `Release ${version} publishes no checksum for ${assetName}.`, + "Refusing to install an unverified daemon. Report this against hoophq/hoop.", + ); + } + return sha; +} + +/** Result of a completed download step. */ +export interface DownloadedDaemon { + path: string; + version: string; + assetName: string; + bytes: number; + sha256: string; +} + +/** + * Download the daemon for `target` at `version` and verify it against + * the release manifest. Returns the staged path (mode 0755). + * + * Reuses `downloadAndInstall` from the self-update path: it already + * streams to a sibling temp file, hashes while writing, discards on + * mismatch, chmods 0755, and renames atomically. Duplicating that here + * would be a second implementation of the same tricky sequence. + */ +export async function downloadDaemon(opts: { + target: DaemonTarget; + version: string; +}): Promise { + const { target, version } = opts; + const assetName = daemonAssetName(target); + const expectedSha256 = await fetchExpectedSha256(version, assetName); + const dest = stagedDaemonPath(target); + const url = `${downloadBase()}/${RELEASE_REPO}/releases/download/${version}/${assetName}`; + + // A stale staged binary from an earlier version would survive the + // rename, but the temp file it renames over would not — clear it so + // a failed verify can never leave last week's daemon in place. + if (existsSync(dest)) { + unlinkSync(dest); + } + + debug("tunnel.install", `downloading ${url} -> ${dest}`); + const result = await downloadAndInstall({ url, expectedSha256, binPath: dest }); + // downloadAndInstall skips chmod on Windows (it replaces a running + // .exe there); on POSIX it already set 0755. Make the staged file + // executable unconditionally so the spawn below cannot fail on mode. + if (process.platform !== "win32") { + chmodSync(dest, 0o755); + } + return { + path: dest, + version, + assetName, + bytes: result.bytesWritten, + sha256: result.computedSha256, + }; +} + +/** + * Run `sudo install`, letting sudo prompt for the password on + * the inherited TTY. + * + * Design notes: + * + * - stdio is inherited. hsh never sees the password; sudo reads it + * straight from the terminal. Any askpass/PAM behaviour the user + * configured (Touch ID, YubiKey) therefore keeps working. + * - Already root (CI images, `sudo hsh setup`) → exec the daemon + * directly. Wrapping root in sudo works but fails on hosts with no + * sudo installed, which is common in containers. + * - Extra args pass straight through so the daemon's flag parser + * stays the single source of truth for install options. + */ +export function runPrivilegedInstall( + daemonPath: string, + args: string[] = [], +): void { + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + const argv = [daemonPath, "install", ...args]; + const cmd = isRoot ? argv[0] : "sudo"; + const cmdArgs = isRoot ? argv.slice(1) : ["--", ...argv]; + + debug("tunnel.install", `spawning ${cmd} ${cmdArgs.join(" ")}`); + // `env` is passed explicitly rather than inherited by default: Bun + // resolves the executable against the PATH snapshot taken at process + // start, so a PATH edited after startup (test harnesses, a shell that + // sourced a profile mid-session) would otherwise pick the wrong sudo. + const res = spawnSync(cmd, cmdArgs, { stdio: "inherit", env: process.env }); + + if (res.error) { + const code = (res.error as NodeJS.ErrnoException).code; + if (!isRoot && code === "ENOENT") { + throw new DaemonInstallError( + "sudo is not available on this host, and the daemon install needs root.", + `Re-run as root: ${daemonPath} install`, + ); + } + throw new DaemonInstallError( + `Could not run the daemon installer: ${res.error.message}`, + `Try it manually: sudo ${daemonPath} install`, + ); + } + if (res.signal) { + throw new DaemonInstallError( + `The daemon installer was terminated by ${res.signal}.`, + `Try it manually: sudo ${daemonPath} install`, + ); + } + if (res.status !== 0) { + throw new DaemonInstallError( + `The daemon installer exited with code ${res.status}.`, + `Re-run it directly to see the full output: sudo ${daemonPath} install`, + ); + } +} + +/** What `installDaemon` did, for the caller's summary output. */ +export interface DaemonInstallResult { + version: string; + assetName: string; + stagedPath: string; + bytes: number; + sha256: string; +} + +/** + * Full flow: resolve target → download + verify → privileged install. + * + * `version` defaults to the daemon version this hsh build was stamped + * with, keeping the CLI and daemon a matched pair. `onProgress` gets + * human-readable milestones so the command layer owns all formatting + * (this module prints nothing itself). + */ +export async function installDaemon(opts: { + version?: string; + installArgs?: string[]; + onProgress?: (message: string) => void; +} = {}): Promise { + const version = opts.version ?? BUNDLED_DAEMON_VERSION; + const progress = opts.onProgress ?? (() => {}); + + const target = daemonTargetFor(process.platform, process.arch); + if (!target) { + throw new DaemonInstallError( + `No hsh-tunneld build exists for ${process.platform}/${process.arch}.`, + "Supported: linux/x64, linux/arm64, darwin/x64, darwin/arm64, win32/x64, win32/arm64.", + ); + } + if (target.goos === "windows") { + // The Go service package's Windows backend is still a stub that + // returns ErrUnsupportedPlatform, so `hsh-tunneld install` would + // download 18 MB and then refuse. Failing before the download is + // the honest answer. + throw new DaemonInstallError( + "hsh-tunneld does not yet support installation as a Windows service.", + "Use hsh without the tunnel for now; Windows service support is in progress.", + ); + } + + progress(`Fetching hsh-tunneld ${version} for ${target.goos}/${target.goarch}`); + let downloaded: DownloadedDaemon; + try { + downloaded = await downloadDaemon({ target, version }); + } catch (err) { + if (err instanceof DaemonInstallError) throw err; + if (err instanceof ApiUnreachableError) { + throw new DaemonInstallError( + `Could not download hsh-tunneld: ${err.reason}.`, + "Check your network (or proxy) and retry.", + ); + } + throw new DaemonInstallError( + `Could not download hsh-tunneld: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + progress(`Verified ${downloaded.assetName} (sha256 ${downloaded.sha256})`); + progress("Registering the daemon with your system service manager (sudo required)"); + runPrivilegedInstall(downloaded.path, opts.installArgs); + + return { + version, + assetName: downloaded.assetName, + stagedPath: downloaded.path, + bytes: downloaded.bytes, + sha256: downloaded.sha256, + }; +} diff --git a/tests/login-command-options.test.ts b/tests/login-command-options.test.ts index 1092390..9d03a69 100644 --- a/tests/login-command-options.test.ts +++ b/tests/login-command-options.test.ts @@ -46,6 +46,10 @@ describe("negatable --no-* options default to true (daemon leg enabled)", () => expect(negatableDefault(loginCommand, "browser")).toBe(true); }); + test("hsh login --no-setup defaults setup=true", () => { + expect(negatableDefault(loginCommand, "setup")).toBe(true); + }); + test("hsh logout --no-tunnel defaults tunnel=true", () => { expect(negatableDefault(logoutCommand, "tunnel")).toBe(true); }); diff --git a/tests/tunnel-installer.test.ts b/tests/tunnel-installer.test.ts new file mode 100644 index 0000000..d2bb0a3 --- /dev/null +++ b/tests/tunnel-installer.test.ts @@ -0,0 +1,351 @@ +/** + * tests/tunnel-installer.test.ts — contract tests for the DEP-141 + * daemon setup flow. + * + * What matters here is the security contract: the binary this module + * downloads is executed as root, so an unverifiable or tampered asset + * must never reach disk, and a stale daemon from a previous install + * must never survive a failed one. Those paths are exercised against a + * local Bun.serve standing in for the GitHub release CDN + * (HSH_DAEMON_RELEASE_BASE points the flow at it). + * + * The privileged leg (`sudo hsh-tunneld install`) is covered through + * runPrivilegedInstall with a fake daemon script: we assert the exit + * status is propagated as a typed error rather than swallowed. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "crypto"; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + DaemonInstallError, + daemonAssetName, + daemonInstallSupported, + daemonTargetFor, + downloadDaemon, + fetchExpectedSha256, + installDaemon, + runPrivilegedInstall, + stagedDaemonPath, +} from "../src/tunnel/installer.ts"; + +const DAEMON_BODY = new TextEncoder().encode("#!/bin/sh\nexit 0\n"); + +let hshHome: string; +let prevHome: string | undefined; +let prevBase: string | undefined; + +function sha256(data: Uint8Array): string { + const h = createHash("sha256"); + h.update(data); + return h.digest("hex"); +} + +interface Stub { + base: string; + stop: () => void; + /** Paths the stub was asked for, in order. */ + hits: string[]; +} + +/** + * Stand in for the hoop release CDN. `manifest` is the SHA256SUMS body + * served for every version; `asset` is the payload served for any + * hsh-tunneld-* request. + */ +function serveRelease(opts: { + manifest?: string | null; + asset?: Uint8Array; +}): Stub { + const hits: string[] = []; + const asset = opts.asset ?? DAEMON_BODY; + const owned = new Uint8Array(asset.byteLength); + owned.set(asset); + + const server = Bun.serve({ + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + hits.push(path); + if (path.endsWith("/SHA256SUMS")) { + if (opts.manifest == null) return new Response("nope", { status: 404 }); + return new Response(opts.manifest, { status: 200 }); + } + return new Response(owned, { status: 200 }); + }, + }); + const port = server.port; + if (typeof port !== "number") throw new Error("Bun.serve gave no port"); + return { base: `http://127.0.0.1:${port}`, stop: () => server.stop(true), hits }; +} + +beforeEach(() => { + hshHome = mkdtempSync(join(tmpdir(), "hsh-installer-")); + prevHome = process.env.HSH_HOME; + prevBase = process.env.HSH_DAEMON_RELEASE_BASE; + process.env.HSH_HOME = hshHome; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HSH_HOME; + else process.env.HSH_HOME = prevHome; + if (prevBase === undefined) delete process.env.HSH_DAEMON_RELEASE_BASE; + else process.env.HSH_DAEMON_RELEASE_BASE = prevBase; + rmSync(hshHome, { recursive: true, force: true }); +}); + +describe("daemonTargetFor", () => { + test("maps the platforms the daemon publishes", () => { + expect(daemonTargetFor("linux", "x64")).toEqual({ goos: "linux", goarch: "amd64" }); + expect(daemonTargetFor("linux", "arm64")).toEqual({ goos: "linux", goarch: "arm64" }); + expect(daemonTargetFor("darwin", "arm64")).toEqual({ goos: "darwin", goarch: "arm64" }); + expect(daemonTargetFor("win32", "x64")).toEqual({ goos: "windows", goarch: "amd64" }); + }); + + test("returns null for architectures and platforms with no published build", () => { + expect(daemonTargetFor("linux", "ia32")).toBeNull(); + expect(daemonTargetFor("linux", "ppc64")).toBeNull(); + expect(daemonTargetFor("freebsd", "x64")).toBeNull(); + }); + + test("asset names match the hoop release filenames", () => { + expect(daemonAssetName({ goos: "linux", goarch: "amd64" })).toBe("hsh-tunneld-linux-amd64"); + expect(daemonAssetName({ goos: "darwin", goarch: "arm64" })).toBe("hsh-tunneld-darwin-arm64"); + expect(daemonAssetName({ goos: "windows", goarch: "amd64" })).toBe( + "hsh-tunneld-windows-amd64.exe", + ); + }); + + test("install is unsupported where it could only fail", () => { + // Windows publishes a daemon binary, but its service backend is a + // stub that refuses — implicit setup must not fire there. + expect(daemonInstallSupported("win32", "x64")).toBe(false); + expect(daemonInstallSupported("linux", "ia32")).toBe(false); + expect(daemonInstallSupported("freebsd", "x64")).toBe(false); + expect(daemonInstallSupported("linux", "x64")).toBe(true); + expect(daemonInstallSupported("darwin", "arm64")).toBe(true); + }); +}); + +describe("fetchExpectedSha256", () => { + test("returns the manifest entry for the asset", async () => { + const stub = serveRelease({ + manifest: [ + `${sha256(DAEMON_BODY)} hsh-tunneld-linux-amd64`, + "aa".repeat(32) + " install.sh", + "", + ].join("\n"), + }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + const got = await fetchExpectedSha256("1.86.0", "hsh-tunneld-linux-amd64"); + expect(got).toBe(sha256(DAEMON_BODY)); + expect(stub.hits[0]).toBe("/hoophq/hoop/releases/download/1.86.0/SHA256SUMS"); + } finally { + stub.stop(); + } + }); + + test("a missing manifest is fatal, not a warning", async () => { + const stub = serveRelease({ manifest: null }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + await expect( + fetchExpectedSha256("1.86.0", "hsh-tunneld-linux-amd64"), + ).rejects.toBeInstanceOf(DaemonInstallError); + } finally { + stub.stop(); + } + }); + + test("a manifest without our asset is fatal", async () => { + const stub = serveRelease({ manifest: `${"bb".repeat(32)} install.sh\n` }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + await expect( + fetchExpectedSha256("1.86.0", "hsh-tunneld-linux-amd64"), + ).rejects.toThrow(/no checksum/i); + } finally { + stub.stop(); + } + }); +}); + +describe("downloadDaemon", () => { + const target = { goos: "linux", goarch: "amd64" } as const; + + test("verifies against the manifest and stages an executable binary", async () => { + const stub = serveRelease({ + manifest: `${sha256(DAEMON_BODY)} hsh-tunneld-linux-amd64\n`, + }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + const got = await downloadDaemon({ target, version: "1.86.0" }); + expect(got.sha256).toBe(sha256(DAEMON_BODY)); + expect(got.bytes).toBe(DAEMON_BODY.length); + expect(got.path).toBe(join(hshHome, "bin", "hsh-tunneld")); + expect(readFileSync(got.path)).toEqual(Buffer.from(DAEMON_BODY)); + expect(statSync(got.path).mode & 0o777).toBe(0o755); + } finally { + stub.stop(); + } + }); + + test("a tampered asset is discarded, leaving nothing to execute", async () => { + const stub = serveRelease({ + // Manifest advertises a hash the served bytes do not have. + manifest: `${"cc".repeat(32)} hsh-tunneld-linux-amd64\n`, + }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + await expect(downloadDaemon({ target, version: "1.86.0" })).rejects.toThrow( + /SHA256 mismatch/, + ); + expect(existsSync(stagedDaemonPath(target))).toBe(false); + } finally { + stub.stop(); + } + }); + + test("a failed verify does not leave the previously staged daemon in place", async () => { + const staged = stagedDaemonPath(target); + writeFileSync(staged, "OLD DAEMON", { mode: 0o755 }); + + const stub = serveRelease({ + manifest: `${"dd".repeat(32)} hsh-tunneld-linux-amd64\n`, + }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + await expect(downloadDaemon({ target, version: "1.86.0" })).rejects.toThrow(); + // Stale binary must be gone: silently keeping it would install a + // daemon version nobody verified for this release. + expect(existsSync(staged)).toBe(false); + } finally { + stub.stop(); + } + }); + + test("leaves no .partial files behind after a successful download", async () => { + const stub = serveRelease({ + manifest: `${sha256(DAEMON_BODY)} hsh-tunneld-linux-amd64\n`, + }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + try { + await downloadDaemon({ target, version: "1.86.0" }); + } finally { + stub.stop(); + } + const leftovers = require("fs") + .readdirSync(join(hshHome, "bin")) + .filter((f: string) => f.includes(".partial")); + expect(leftovers).toEqual([]); + }); +}); + +describe("runPrivilegedInstall", () => { + // These run the "already root" branch only when the test process is + // root (CI containers); otherwise the sudo branch would prompt. We + // therefore drive the non-root path through a fake `sudo` on PATH. + let fakeBin: string; + let prevPath: string | undefined; + + beforeEach(() => { + fakeBin = mkdtempSync(join(tmpdir(), "hsh-fakesudo-")); + prevPath = process.env.PATH; + // Fake sudo: strips the leading `--` and execs the rest, so the + // daemon's own exit code reaches us exactly as real sudo would. + const sudo = `#!/bin/sh +[ "$1" = "--" ] && shift +exec "$@" +`; + writeFileSync(join(fakeBin, "sudo"), sudo); + chmodSync(join(fakeBin, "sudo"), 0o755); + process.env.PATH = `${fakeBin}:${prevPath ?? ""}`; + }); + + afterEach(() => { + if (prevPath === undefined) delete process.env.PATH; + else process.env.PATH = prevPath; + rmSync(fakeBin, { recursive: true, force: true }); + }); + + test("passes the install verb and extra args through to the daemon", () => { + const marker = join(fakeBin, "argv.txt"); + const daemon = join(fakeBin, "hsh-tunneld"); + writeFileSync(daemon, `#!/bin/sh\necho "$@" > ${marker}\nexit 0\n`); + chmodSync(daemon, 0o755); + + runPrivilegedInstall(daemon, ["--no-start"]); + expect(readFileSync(marker, "utf-8").trim()).toBe("install --no-start"); + }); + + test("a non-zero installer exit becomes a typed error with a retry hint", () => { + const daemon = join(fakeBin, "hsh-tunneld"); + writeFileSync(daemon, "#!/bin/sh\nexit 3\n"); + chmodSync(daemon, 0o755); + + let caught: unknown; + try { + runPrivilegedInstall(daemon); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(DaemonInstallError); + expect((caught as DaemonInstallError).message).toContain("exited with code 3"); + expect((caught as DaemonInstallError).hint).toContain(daemon); + }); +}); + +describe("installDaemon", () => { + test("refuses Windows before spending a download on it", async () => { + // hsh-tunneld's Windows service backend is still a stub that + // returns ErrUnsupportedPlatform, so downloading 18 MB first would + // just waste the user's bandwidth. + if (process.platform !== "win32") return; + await expect(installDaemon()).rejects.toBeInstanceOf(DaemonInstallError); + }); + + test("reports progress milestones in order", async () => { + if (process.platform === "win32") return; + const target = daemonTargetFor(process.platform, process.arch); + if (!target) return; // unsupported host arch; nothing to assert + + const asset = daemonAssetName(target); + const stub = serveRelease({ manifest: `${sha256(DAEMON_BODY)} ${asset}\n` }); + process.env.HSH_DAEMON_RELEASE_BASE = stub.base; + + // The "daemon" this stub serves is a shell script that exits 0, so + // the privileged step actually runs end to end through a fake sudo + // and the flow completes — proving the three milestones fire in the + // documented order rather than short-circuiting on an error. + const fakeBin = mkdtempSync(join(tmpdir(), "hsh-fakesudo2-")); + const prevPath = process.env.PATH; + writeFileSync(join(fakeBin, "sudo"), '#!/bin/sh\n[ "$1" = "--" ] && shift\nexec "$@"\n'); + chmodSync(join(fakeBin, "sudo"), 0o755); + process.env.PATH = `${fakeBin}:${prevPath ?? ""}`; + + const messages: string[] = []; + try { + await installDaemon({ version: "1.86.0", onProgress: (m) => messages.push(m) }); + } finally { + stub.stop(); + if (prevPath === undefined) delete process.env.PATH; + else process.env.PATH = prevPath; + rmSync(fakeBin, { recursive: true, force: true }); + } + + expect(messages[0]).toContain(`hsh-tunneld 1.86.0 for ${target.goos}/${target.goarch}`); + expect(messages[1]).toContain(sha256(DAEMON_BODY)); + expect(messages[2]).toContain("sudo required"); + }); +});