diff --git a/apps/web/public/_headers b/apps/web/public/_headers index e8c1172..47b6c69 100644 --- a/apps/web/public/_headers +++ b/apps/web/public/_headers @@ -10,3 +10,13 @@ Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=() Strict-Transport-Security: max-age=31536000; includeSubDomains + +# One-line CLI installers (curl … | sh, irm … | iex): plain text, never +# cached long, so a fixed installer reaches everyone at once. +/install.sh + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300 + +/install.ps1 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300 diff --git a/apps/web/public/install.ps1 b/apps/web/public/install.ps1 new file mode 100644 index 0000000..7d08f34 --- /dev/null +++ b/apps/web/public/install.ps1 @@ -0,0 +1,220 @@ +# Install the BridgeHook CLI (Windows). +# +# irm https://bridgehook.dev/install.ps1 | iex +# +# Downloads bridgehook.exe from the newest stable CLI release on GitHub +# (tags cli-v), checks that the download is intact against the +# release's SHA256SUMS, installs it to %LOCALAPPDATA%\bridgehook\bin and adds +# that folder to your user PATH (no administrator rights). Running it again +# upgrades in place. Nothing is installed unless the checksum matches. +# Works in Windows PowerShell 5.1 and PowerShell 7. +# +# $env:BRIDGEHOOK_VERSION = '0.1.0' a specific version +# $env:BRIDGEHOOK_INSTALL_DIR = 'C:\...' where to install +# $env:BRIDGEHOOK_DOWNLOAD_URL = 'https://...' a directory holding the +# binaries and SHA256SUMS (mirrors) +# +# macOS and Linux: curl -fsSL https://bridgehook.dev/install.sh | sh + +& { + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version 3.0 + + $Repo = 'hallelx2/bridgehook' + # Overridable only so tests can stand in for GitHub. + $GitHub = if ($env:BRIDGEHOOK_GITHUB_URL) { $env:BRIDGEHOOK_GITHUB_URL.TrimEnd('/') } else { 'https://github.com' } + $GitHubApi = if ($env:BRIDGEHOOK_GITHUB_API_URL) { $env:BRIDGEHOOK_GITHUB_API_URL.TrimEnd('/') } else { 'https://api.github.com' } + + # Messages for the person watching: Write-Output would land in the + # pipeline of `irm | iex`, and Write-Information is hidden by default. + function Say { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] + param([string]$Message = '') + Write-Host $Message + } + + function Fail([string]$Message) { + throw "bridgehook install: $Message" + } + + # The newest stable CLI release. CLI releases are tagged cli-v; + # the repository has other releases too, and prereleases are never + # picked. The API lists newest first and leaves out drafts. + function Get-NewestCliVersion { + try { + $releases = Invoke-RestMethod -UseBasicParsing -Uri "$GitHubApi/repos/$Repo/releases?per_page=50" -Headers @{ 'User-Agent' = 'bridgehook-install' } + # Windows PowerShell 5.1 passes a JSON array down the pipeline as + # one object, so walk it explicitly. + foreach ($release in @($releases)) { + if ($release.tag_name -like 'cli-v[0-9]*' -and -not $release.prerelease) { + return $release.tag_name.Substring(5) + } + } + } catch { + $null = $_ # fall through to the redirect below + } + # The API failed (60 requests an hour per IP; offices and CI hit it) or + # listed no CLI release: GitHub's latest-release redirect is not limited + # the same way. The publish script marks CLI releases latest. + $tag = Get-LatestRedirectTag + if ($tag -like 'cli-v[0-9]*') { return $tag.Substring(5) } + Fail "cannot find the newest CLI release (GitHub's API may be rate-limiting this network). Set `$env:BRIDGEHOOK_VERSION = '0.1.0' (for example) and run again, or see https://github.com/$Repo/releases" + } + + # The tag GitHub's /releases/latest redirects to, or $null. + function Get-LatestRedirectTag { + try { + $request = [Net.HttpWebRequest]::Create("$GitHub/$Repo/releases/latest") + $request.AllowAutoRedirect = $false + $request.Method = 'HEAD' + $request.UserAgent = 'bridgehook-install' + $response = $request.GetResponse() + try { + $location = $response.Headers['Location'] + } finally { + $response.Close() + } + if ($location -match '/tag/([A-Za-z0-9._+-]+)$') { return $Matches[1] } + } catch { + $null = $_ + } + return $null + } + + # Adds a folder to the user PATH (HKCU\Environment, no administrator + # rights). The value is read and written raw, as REG_EXPAND_SZ, so entries + # such as %USERPROFILE%\bin stay references instead of being frozen to + # today's expansion; open programs are then told the environment changed. + function Add-UserPath([string]$Dir) { + $key = Get-Item -LiteralPath 'HKCU:\Environment' + $raw = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $entries = @($raw -split ';' | Where-Object { $_ }) + $present = $entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') -ieq $Dir.TrimEnd('\') } + if ($present) { return $false } + Set-ItemProperty -LiteralPath 'HKCU:\Environment' -Name 'Path' -Type ExpandString -Value (($entries + $Dir) -join ';') + Send-EnvironmentChanged + return $true + } + + # WM_SETTINGCHANGE "Environment", as setx and the System settings send. + function Send-EnvironmentChanged { + try { + if (-not ('BridgeHookInstall.Native' -as [type])) { + Add-Type -Namespace BridgeHookInstall -Name Native -MemberDefinition @' +[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] +public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); +'@ + } + $result = [UIntPtr]::Zero + [void][BridgeHookInstall.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) + } catch { + $null = $_ # new terminals still read the registry + } + } + + # Windows PowerShell 5.1 may default to TLS 1.0, which GitHub refuses. + if ($PSVersionTable.PSVersion.Major -lt 6) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + + $onWindows = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows + + # One build for now: x64, which Windows on ARM runs under emulation. + $target = 'x86_64-pc-windows-gnu' + if ($env:BRIDGEHOOK_TARGET) { $target = $env:BRIDGEHOOK_TARGET } + $asset = "bridgehook-$target.exe" + + if ($env:BRIDGEHOOK_INSTALL_DIR) { + $installDir = $env:BRIDGEHOOK_INSTALL_DIR + } elseif ($onWindows) { + $installDir = Join-Path $env:LOCALAPPDATA 'bridgehook\bin' + } else { + Fail 'this script is for Windows; on macOS and Linux run: curl -fsSL https://bridgehook.dev/install.sh | sh' + } + + if ($env:BRIDGEHOOK_DOWNLOAD_URL) { + $base = $env:BRIDGEHOOK_DOWNLOAD_URL.TrimEnd('/') + $version = if ($env:BRIDGEHOOK_VERSION) { $env:BRIDGEHOOK_VERSION } else { 'unknown' } + } else { + if ($env:BRIDGEHOOK_VERSION) { + $version = $env:BRIDGEHOOK_VERSION.TrimStart('v') + } else { + $version = Get-NewestCliVersion + } + $base = "$GitHub/$Repo/releases/download/cli-v$version" + } + + Say "Installing bridgehook $version for $target" + + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("bridgehook-" + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + $exe = Join-Path $tmp $asset + $sums = Join-Path $tmp 'SHA256SUMS' + # The progress bar makes Invoke-WebRequest many times slower in 5.1. + $oldProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile $exe + Invoke-WebRequest -UseBasicParsing -Uri "$base/SHA256SUMS" -OutFile $sums + } catch { + Fail "download failed from ${base}: $($_.Exception.Message)" + } finally { + $ProgressPreference = $oldProgress + } + + $expected = $null + foreach ($line in Get-Content -LiteralPath $sums) { + $parts = $line -split '\s+', 2 + if ($parts.Count -eq 2 -and ($parts[1] -eq $asset -or $parts[1] -eq "*$asset")) { + $expected = $parts[0].ToLowerInvariant() + break + } + } + if (-not $expected) { Fail "$asset is not listed in SHA256SUMS; not installing" } + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $exe).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Fail "checksum mismatch for $asset (expected $expected, got $actual); not installing" + } + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $dest = Join-Path $installDir 'bridgehook.exe' + $staged = Join-Path $installDir 'bridgehook.exe.new' + Copy-Item -LiteralPath $exe -Destination $staged -Force + if (Test-Path -LiteralPath $dest) { + # A running bridgehook.exe cannot be overwritten, but it can be + # renamed; the old copy is removed on the next install. + $old = Join-Path $installDir 'bridgehook.exe.old' + Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $dest -Destination $old -Force + } + Move-Item -LiteralPath $staged -Destination $dest -Force + Remove-Item -LiteralPath (Join-Path $installDir 'bridgehook.exe.old') -Force -ErrorAction SilentlyContinue + } finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue + } + + $installed = $null + if ($onWindows) { + try { $installed = & $dest --version } catch { $installed = $null } + } + if ($installed) { + Say "Installed $installed to $dest" + } else { + Say "Installed bridgehook to $dest" + } + + # A custom install directory is left off PATH unless asked for. + $addToPath = $onWindows -and ((-not $env:BRIDGEHOOK_INSTALL_DIR) -or $env:BRIDGEHOOK_ADD_TO_PATH) + if ($addToPath) { + if (Add-UserPath $installDir) { + Say "Added $installDir to your user PATH; open a new terminal to use bridgehook." + } + if (-not (($env:Path -split ';') | Where-Object { $_.TrimEnd('\') -ieq $installDir.TrimEnd('\') })) { + $env:Path = "$env:Path;$installDir" + } + } + + Say + Say 'Next: bridgehook login, then bridgehook forward 3000' +} diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh new file mode 100644 index 0000000..57337e1 --- /dev/null +++ b/apps/web/public/install.sh @@ -0,0 +1,219 @@ +#!/bin/sh +# Install the BridgeHook CLI (macOS, Linux). +# +# curl -fsSL https://bridgehook.dev/install.sh | sh +# +# Downloads the `bridgehook` binary for this OS and CPU from the newest +# stable CLI release on GitHub (tags `cli-v`), checks that the +# download is intact against the release's SHA256SUMS, and installs it to +# ~/.local/bin. Running it again upgrades in place. Nothing is installed +# unless the checksum matches. +# +# BRIDGEHOOK_VERSION=0.1.0 a specific version instead of the newest +# BRIDGEHOOK_INSTALL_DIR=/path where to install (default ~/.local/bin) +# BRIDGEHOOK_DOWNLOAD_URL=url a directory holding the binaries and +# SHA256SUMS (mirrors) +# +# Windows: irm https://bridgehook.dev/install.ps1 | iex + +set -eu + +REPO="hallelx2/bridgehook" +# Overridable only so tests can stand in for GitHub. +GITHUB=${BRIDGEHOOK_GITHUB_URL:-https://github.com} +GITHUB_API=${BRIDGEHOOK_GITHUB_API_URL:-https://api.github.com} + +say() { printf '%s\n' "$*"; } +err() { + printf 'bridgehook install: %s\n' "$*" >&2 + exit 1 +} + +# Everything runs inside main, so a download cut short mid-script (curl | sh) +# runs nothing. +main() { + need uname + need mktemp + need chmod + need mkdir + need mv + + target=$(detect_target) + install_dir=${BRIDGEHOOK_INSTALL_DIR:-$HOME/.local/bin} + + if [ -n "${BRIDGEHOOK_DOWNLOAD_URL:-}" ]; then + base=${BRIDGEHOOK_DOWNLOAD_URL%/} + version=${BRIDGEHOOK_VERSION:-unknown} + else + version=$(resolve_version) + base="$GITHUB/$REPO/releases/download/cli-v$version" + fi + + asset="bridgehook-$target" + tmp=$(mktemp -d 2>/dev/null || mktemp -d -t bridgehook) + # EXIT cleans up on every path; INT and TERM must also stop the script, + # which a bare cleanup handler does not do in dash or busybox sh. + trap 'rm -rf "$tmp"' EXIT + trap 'rm -rf "$tmp"; exit 130' INT TERM + + say "Installing bridgehook ${version} for ${target}" + download "$base/$asset" "$tmp/$asset" + download "$base/SHA256SUMS" "$tmp/SHA256SUMS" + verify "$tmp" "$asset" + + mkdir -p "$install_dir" || err "cannot create $install_dir" + chmod 755 "$tmp/$asset" + # Move within the install directory so the swap is a rename: an existing + # bridgehook is replaced in one step, never left half written. + staged="$install_dir/.bridgehook.new.$$" + mv "$tmp/$asset" "$staged" 2>/dev/null || { cp "$tmp/$asset" "$staged" && chmod 755 "$staged"; } || + err "cannot write to $install_dir (set BRIDGEHOOK_INSTALL_DIR to a directory you own)" + mv -f "$staged" "$install_dir/bridgehook" || { + rm -f "$staged" + err "cannot replace $install_dir/bridgehook" + } + + installed=$("$install_dir/bridgehook" --version 2>/dev/null || true) + say "Installed ${installed:-bridgehook} to $install_dir/bridgehook" + + case ":$PATH:" in + *":$install_dir:"*) ;; + *) + say "" + say "$install_dir is not on your PATH. Add it, then open a new terminal:" + case ${SHELL:-} in + */fish) say " fish_add_path $install_dir" ;; + *) say " echo 'export PATH=\"$install_dir:\$PATH\"' >> $(profile_file)" ;; + esac + ;; + esac + say "" + say "Next: bridgehook login, then bridgehook forward 3000" +} + +need() { + command -v "$1" >/dev/null 2>&1 || err "needs '$1', which is not installed" +} + +detect_target() { + os=$(uname -s) + arch=$(uname -m) + case $os in + Linux) os_part=unknown-linux-musl ;; + Darwin) os_part=apple-darwin ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + err "on Windows, run in PowerShell: irm https://bridgehook.dev/install.ps1 | iex" ;; + *) err "no build for $os yet; see https://github.com/$REPO/releases" ;; + esac + case $arch in + x86_64 | amd64) arch_part=x86_64 ;; + aarch64 | arm64) arch_part=aarch64 ;; + *) err "no build for $os on $arch yet; see https://github.com/$REPO/releases" ;; + esac + # An Intel shell under Rosetta on Apple silicon: install the native build. + if [ "$os" = Darwin ] && [ "$arch_part" = x86_64 ] && + [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + arch_part=aarch64 + fi + printf '%s-%s' "$arch_part" "$os_part" +} + +# The newest stable CLI release, or BRIDGEHOOK_VERSION. CLI releases are +# tagged cli-v; the repository has other releases too, and +# prereleases (cli-v0.2.0-rc1) are never picked. +resolve_version() { + if [ -n "${BRIDGEHOOK_VERSION:-}" ]; then + printf '%s' "${BRIDGEHOOK_VERSION#v}" + return + fi + if json=$(fetch_text "$GITHUB_API/repos/$REPO/releases?per_page=50" 2>/dev/null); then + v=$(printf '%s\n' "$json" | newest_stable_cli) + [ -n "$v" ] && { printf '%s' "$v"; return; } + fi + # The API failed (it allows 60 requests an hour per IP, so offices and CI + # hit it) or listed no CLI release: GitHub's latest-release redirect is + # not limited the same way. The publish script marks CLI releases latest. + tag=$(latest_redirect_tag) + case $tag in + cli-v[0-9]*) printf '%s' "${tag#cli-v}" ;; + *) err "cannot find the newest CLI release (GitHub's API may be rate-limiting this network). Set BRIDGEHOOK_VERSION, e.g. BRIDGEHOOK_VERSION=0.1.0, or see https://github.com/$REPO/releases" ;; + esac +} + +# Reads the releases API's JSON on stdin and prints the newest cli-v version +# that is not a prerelease. Splitting on commas puts each field on its own +# line whether the JSON is pretty-printed or not; within a release, +# "tag_name" comes before "prerelease". The API lists newest first and +# leaves out drafts. +newest_stable_cli() { + tr ',' '\n' | awk ' + /"tag_name"[[:space:]]*:/ { + tag = $0 + sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag) + sub(/".*/, "", tag) + } + /"prerelease"[[:space:]]*:/ { + if (tag ~ /^cli-v[0-9]/ && $0 ~ /false/) { sub(/^cli-v/, "", tag); print tag; exit } + tag = "" + }' +} + +# The tag GitHub's /releases/latest redirects to, or nothing. +latest_redirect_tag() { + url="$GITHUB/$REPO/releases/latest" + if command -v curl >/dev/null 2>&1; then + loc=$(curl -fsSI -o /dev/null -w '%{redirect_url}' "$url" 2>/dev/null || true) + elif command -v wget >/dev/null 2>&1; then + loc=$(wget -S --max-redirect=0 -O /dev/null "$url" 2>&1 | sed -n 's/^ *[Ll]ocation: *//p' | tr -d '\r' | tail -n 1) + else + loc= + fi + printf '%s' "${loc##*/tag/}" | sed 's/[^A-Za-z0-9._+-].*//' +} + +fetch_text() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 "$1" + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$1" + else + err "needs curl or wget" + fi +} + +download() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "$2" "$1" || err "download failed: $1" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" || err "download failed: $1" + else + err "needs curl or wget" + fi +} + +verify() { + dir=$1 + file=$2 + expected=$(awk -v f="$file" '$2 == f || $2 == "*" f { print $1; exit }' "$dir/SHA256SUMS") + [ -n "$expected" ] || err "$file is not listed in SHA256SUMS; not installing" + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$dir/$file" | awk '{ print $1 }') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$dir/$file" | awk '{ print $1 }') + elif command -v openssl >/dev/null 2>&1; then + actual=$(openssl dgst -sha256 "$dir/$file" | awk '{ print $NF }') + else + err "needs sha256sum, shasum or openssl to check the download" + fi + [ "$actual" = "$expected" ] || err "checksum mismatch for $file (expected $expected, got $actual); not installing" +} + +profile_file() { + case ${SHELL:-} in + */zsh) printf '%s' "$HOME/.zshrc" ;; + */bash) printf '%s' "$HOME/.bashrc" ;; + *) printf '%s' "$HOME/.profile" ;; + esac +} + +main "$@" diff --git a/cli/README.md b/cli/README.md index afe8554..3b0a2a9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,6 +2,20 @@ A stable public webhook URL that forwards to a local port. Webhooks sent while nothing is forwarding wait on the relay, and the next `bridgehook forward` delivers them in order. +## Install + +```sh +curl -fsSL https://bridgehook.dev/install.sh | sh # macOS, Linux (x86_64, arm64) +``` + +```powershell +irm https://bridgehook.dev/install.ps1 | iex # Windows +``` + +Both install the newest stable `cli-v*` release from GitHub (never a prerelease) after checking that the download is intact against the release's `SHA256SUMS`, and install nothing if it does not match. The checksums come from the same release, so they catch a corrupted or cut-off download, not a tampered release. If GitHub's API rate-limits your network, they fall back to the repository's latest-release redirect. Re-run to upgrade. `BRIDGEHOOK_VERSION=0.1.0` pins a version and `BRIDGEHOOK_INSTALL_DIR` changes where it goes (default `~/.local/bin`, or `%LOCALAPPDATA%\bridgehook\bin` added to your user PATH). From source: `cargo install --path cli`. + +## Use + ```sh bridgehook login # pair this machine (approve the code in the browser) bridgehook forward 3000 # prints https://.bridgehook.dev, forwards to localhost:3000 @@ -30,6 +44,17 @@ Ctrl-C finishes the webhook in hand and stops; a second Ctrl-C quits at once. The protocol lives in `crates/bridgehook-core`, a port of the extension's `QueueDrainer`: the queue is read oldest first, each webhook is claimed before it is forwarded (so the extension and the CLI never both deliver it), a server that is down holds the queue without using up retries, and a webhook that fails three times while the server is up is recorded and skipped. The live stream (`/api/me/stream`) only wakes the drain, so a live webhook never overtakes a queued one. Requests are signed with the channel's P-256 key. +## Releasing + +```sh +pnpm release:cli # cross-build the 5 binaries + SHA256SUMS into dist/cli/v +pnpm test:install # both installers in clean glibc, musl and PowerShell containers +scripts/publish-cli-release.sh # upload as a draft cli-v release +scripts/publish-cli-release.sh --publish # or publish at once; the installers pick it up +``` + +The version comes from `[workspace.package]` in `Cargo.toml`. Builds need Docker (the cargo-zigbuild image carries the macOS SDK and the musl and Windows toolchains). + ## Checks ```sh diff --git a/crates/bridgehook-core/src/config.rs b/crates/bridgehook-core/src/config.rs index c5361ae..60904ed 100644 --- a/crates/bridgehook-core/src/config.rs +++ b/crates/bridgehook-core/src/config.rs @@ -184,15 +184,31 @@ impl ChannelMap { mod tests { use super::*; - fn temp_dir() -> PathBuf { - let mut raw = [0u8; 8]; - rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut raw); - std::env::temp_dir().join(format!("bh-config-test-{}", hex::encode(raw))) + /// A unique directory under the system temp dir, removed when dropped + /// so test runs do not pile up in /tmp. + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let mut raw = [0u8; 8]; + rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut raw); + Self(std::env::temp_dir().join(format!("bh-config-test-{}", hex::encode(raw)))) + } + fn paths(&self) -> Paths { + Paths::at(&self.0) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } } #[test] fn token_round_trips_and_is_private() { - let paths = Paths::at(temp_dir()); + let dir = TempDir::new(); + let paths = dir.paths(); assert_eq!(load_token(&paths).unwrap(), None); let token = StoredToken { token: "dvc_abc".into(), @@ -222,7 +238,8 @@ mod tests { #[test] fn an_existing_config_dir_keeps_its_mode() { use std::os::unix::fs::PermissionsExt; - let dir = temp_dir(); + let tmp = TempDir::new(); + let dir = tmp.0.clone(); fs::create_dir_all(&dir).unwrap(); fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); let paths = Paths::at(&dir); @@ -242,7 +259,8 @@ mod tests { #[test] fn executor_id_is_stable() { - let paths = Paths::at(temp_dir()); + let dir = TempDir::new(); + let paths = dir.paths(); let a = executor_id(&paths).unwrap(); let b = executor_id(&paths).unwrap(); assert_eq!(a, b); @@ -251,7 +269,8 @@ mod tests { #[test] fn channel_map_is_per_relay() { - let paths = Paths::at(temp_dir()); + let dir = TempDir::new(); + let paths = dir.paths(); let mut map = ChannelMap::load(&paths).unwrap(); map.set("https://a", "3000", "ch1"); map.set("https://b", "3000", "ch2"); diff --git a/docs/src/pages/Quickstart.tsx b/docs/src/pages/Quickstart.tsx index 363a7b4..00074c4 100644 --- a/docs/src/pages/Quickstart.tsx +++ b/docs/src/pages/Quickstart.tsx @@ -40,6 +40,38 @@ export function Quickstart() { Chrome is open.

+

From the terminal (CLI)

+

+ For servers, containers and anyone who lives in a terminal. One line installs the{" "} + bridgehook binary (macOS, Linux; x86_64 and arm64): +

+
+				curl -fsSL https://bridgehook.dev/install.sh | sh
+			
+

On Windows, in PowerShell:

+
+				irm https://bridgehook.dev/install.ps1 | iex
+			
+

+ Both check that the download is intact against the release's SHA-256 checksums and + install nothing if it does not match. Re-run either line to upgrade; set{" "} + BRIDGEHOOK_VERSION to install a specific version. Then pair the machine and + forward a port: +

+
+				{`bridgehook login          # approve the code in your browser
+bridgehook forward 3000   # prints https://.bridgehook.dev`}
+			
+

+ forward keeps the same URL for the same port across restarts, delivers webhooks + that arrived while it was stopped in order, and prints each one as it goes to your server. + Add --json for one JSON object per line, or --path /stripe to + accept only that path. Where you cannot open a browser (a container, CI), set{" "} + BRIDGEHOOK_TOKEN to a device token: the token field of{" "} + ~/.config/bridgehook/token.json on a machine you paired, which you can revoke + from the dashboard's device list. +

+

With nothing installed

Open{" "} @@ -73,7 +105,7 @@ export function Quickstart() {

- If neither the extension nor a dashboard tab is forwarding, webhooks are stored and + If nothing is forwarding (no extension, CLI or dashboard tab), webhooks are stored and delivered in arrival order when one starts. How long history is kept depends on your plan; see Billing. diff --git a/package.json b/package.json index 7c9ef52..69bce9a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "test:cli": "cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings", "e2e:cli": "node cli/e2e/cli.e2e.mjs", "e2e:cli:local": "node cli/e2e/cli.e2e.mjs --local", + "release:cli": "scripts/release-cli.sh", + "test:install": "scripts/test-install.sh", "deploy:relay": "pnpm --filter @bridgehook/relay deploy", "deploy:web": "pnpm --filter @bridgehook/web deploy", "typecheck": "pnpm -r typecheck", diff --git a/scripts/lint-install-ps1.ps1 b/scripts/lint-install-ps1.ps1 new file mode 100644 index 0000000..b034374 --- /dev/null +++ b/scripts/lint-install-ps1.ps1 @@ -0,0 +1,31 @@ +# Lint apps/web/public/install.ps1 with PSScriptAnalyzer: warnings and +# errors, the PSGallery rule set, and syntax compatibility with Windows +# PowerShell 5.1 and PowerShell 7. Exits 1 on any finding. +# +# docker run --rm -v "$PWD:/src:ro" mcr.microsoft.com/powershell:latest \ +# pwsh -NoProfile -File /src/scripts/lint-install-ps1.ps1 +param([string]$Path = (Join-Path $PSScriptRoot '../apps/web/public/install.ps1')) + +$ErrorActionPreference = 'Stop' +# Pinned: reproducible, and 1.23+ refuses PowerShell older than 7.4.6. +$analyzerVersion = '1.22.0' +if (-not (Get-Module -ListAvailable PSScriptAnalyzer | Where-Object { $_.Version -eq $analyzerVersion })) { + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -RequiredVersion $analyzerVersion -Scope CurrentUser -Force +} +Import-Module PSScriptAnalyzer -RequiredVersion $analyzerVersion + +$findings = @() +$findings += Invoke-ScriptAnalyzer -Path $Path -Severity Warning, Error +$findings += Invoke-ScriptAnalyzer -Path $Path -Settings PSGallery -Severity Warning, Error +$findings += Invoke-ScriptAnalyzer -Path $Path -IncludeRule PSUseCompatibleSyntax -Settings @{ + Rules = @{ PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @('5.1', '7.0') } } +} +$findings = $findings | Sort-Object Line, RuleName -Unique + +if ($findings) { + $findings | Format-Table RuleName, Severity, Line, Message -AutoSize -Wrap | Out-String -Width 220 | Write-Host + Write-Host "$(@($findings).Count) finding(s) in $Path" + exit 1 +} +Write-Host "PSScriptAnalyzer: no findings in $Path (warnings, errors, PSGallery, 5.1/7 syntax)" diff --git a/scripts/publish-cli-release.sh b/scripts/publish-cli-release.sh new file mode 100755 index 0000000..9d27565 --- /dev/null +++ b/scripts/publish-cli-release.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Upload a built CLI release (scripts/release-cli.sh) to GitHub as cli-v. +# +# scripts/publish-cli-release.sh # draft: nothing public yet +# scripts/publish-cli-release.sh --publish # public: the installers pick it up +# +# install.sh and install.ps1 install the newest stable (non-draft, +# non-prerelease) cli-v* release, so a draft can be checked (download it, +# run scripts/test-install.sh on it) before anyone can install it. +# Publishing a draft later: +# gh release edit cli-v --draft=false --latest +# The release is marked "latest": when GitHub's API rate-limits an install, +# the installers fall back to the repository's latest-release redirect. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml") +TAG="cli-v$VERSION" +DIST="$ROOT/dist/cli/v$VERSION" +draft=(--draft) +[ "${1:-}" = --publish ] && draft=() + +[ -f "$DIST/SHA256SUMS" ] || { echo "no build in $DIST; run scripts/release-cli.sh" >&2; exit 1; } + +# The binaries must come from the commit the tag names, and that commit +# must be on main, so anyone can rebuild and compare what they install. +HEAD_COMMIT=$(git -C "$ROOT" rev-parse HEAD) +[ -z "$(git -C "$ROOT" status --porcelain)" ] || { echo "uncommitted changes; publish from a clean checkout of main" >&2; exit 1; } +git -C "$ROOT" fetch -q origin main +git -C "$ROOT" merge-base --is-ancestor "$HEAD_COMMIT" origin/main || + { echo "HEAD ($HEAD_COMMIT) is not on origin/main; merge first, then build and publish from main" >&2; exit 1; } +built=$(cat "$DIST/COMMIT" 2>/dev/null || true) +[ "$built" = "$HEAD_COMMIT" ] || + { echo "$DIST was built from ${built:-an unknown commit}, not HEAD ($HEAD_COMMIT); run scripts/release-cli.sh again" >&2; exit 1; } +(cd "$DIST" && sha256sum -c --quiet SHA256SUMS) || { echo "SHA256SUMS does not match $DIST" >&2; exit 1; } +if gh release view "$TAG" >/dev/null 2>&1; then + echo "$TAG already exists; bump the workspace version in Cargo.toml for a new release" >&2 + exit 1 +fi + +notes=$(mktemp) +trap 'rm -f "$notes"' EXIT +cat >"$notes" < dist/cli/v/ +# +# Cross-compiles in the cargo-zigbuild container (Linux musl, macOS with its +# SDK, Windows), so one Linux machine with Docker builds all five. Release +# assets are bare binaries, which install.sh and install.ps1 download and +# check against SHA256SUMS: +# +# bridgehook-x86_64-unknown-linux-musl +# bridgehook-aarch64-unknown-linux-musl +# bridgehook-x86_64-apple-darwin +# bridgehook-aarch64-apple-darwin +# bridgehook-x86_64-pc-windows-gnu.exe +# +# OFFLINE=1 builds from the crates already in ~/.cargo (no network in the +# container). Publish with scripts/publish-cli-release.sh, which only takes +# a build of a clean, pushed commit on main; the commit is recorded in +# dist/cli/v/COMMIT. ALLOW_DIRTY=1 builds uncommitted changes for a +# local try (such a build cannot be published). +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +IMAGE=${CARGO_ZIGBUILD_IMAGE:-ghcr.io/rust-cross/cargo-zigbuild:latest} +TARGETS=( + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl + x86_64-apple-darwin + aarch64-apple-darwin + x86_64-pc-windows-gnu +) + +if [ -n "$(git -C "$ROOT" status --porcelain)" ] && [ "${ALLOW_DIRTY:-0}" != 1 ]; then + echo "the tree has uncommitted changes; commit them (or ALLOW_DIRTY=1 for a local try)" >&2 + exit 1 +fi +COMMIT=$(git -C "$ROOT" rev-parse HEAD) +[ -z "$(git -C "$ROOT" status --porcelain)" ] || COMMIT="$COMMIT-dirty" + +VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml") +[ -n "$VERSION" ] || { echo "no workspace version in Cargo.toml" >&2; exit 1; } +OUT="$ROOT/dist/cli/v$VERSION" +rm -rf "$OUT" +mkdir -p "$OUT" + +CARGO_HOME_HOST=${CARGO_HOME:-$HOME/.cargo} +mkdir -p "$CARGO_HOME_HOST" +docker_args=( + --rm + -u "$(id -u):$(id -g)" + -v "$ROOT:/src" + -v "$CARGO_HOME_HOST:/cargo" + -e CARGO_HOME=/cargo + -e CARGO_TARGET_DIR=/src/target/cross + -e ZIG_GLOBAL_CACHE_DIR=/src/target/cross/zig-cache + -e HOME=/tmp + -w /src +) +offline=() +if [ "${OFFLINE:-0}" = 1 ]; then + docker_args+=(--network none) + offline=(--offline) +fi + +for target in "${TARGETS[@]}"; do + echo "==> $target" + docker run "${docker_args[@]}" "$IMAGE" \ + cargo zigbuild --locked "${offline[@]}" --release -p bridgehook --target "$target" + src="$ROOT/target/cross/$target/release/bridgehook" + name="bridgehook-$target" + case $target in *windows*) src="$src.exe"; name="$name.exe" ;; esac + cp "$src" "$OUT/$name" +done + +(cd "$OUT" && sha256sum bridgehook-* > SHA256SUMS) +echo "$COMMIT" > "$OUT/COMMIT" +echo +echo "v$VERSION (commit $COMMIT):" +for f in "$OUT"/bridgehook-*; do + printf '%10s %s\n' "$(wc -c <"$f")" "${f##*/}" +done +cat "$OUT/SHA256SUMS" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000..94d7d0f --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Run apps/web/public/install.sh in clean containers against a local mirror +# of a release (scripts/release-cli.sh output), with no internet access. +# +# scripts/test-install.sh [dist/cli/v] +# +# The mirror also stands in for GitHub (BRIDGEHOOK_GITHUB_URL and +# BRIDGEHOOK_GITHUB_API_URL): a releases API whose newest entry is a +# prerelease, then a non-CLI release, then the stable CLI release (pretty +# and minified JSON); a rate-limited API (403) with GitHub's latest-release +# redirect to fall back on; and a redirect to a non-CLI release. +# +# Checks: install on glibc (curl) and musl (busybox wget); the installed +# binary runs; re-running upgrades in place; a tampered binary or a missing +# SHA256SUMS entry aborts with nothing installed; the PATH hint appears when +# the install directory is not on PATH. install.ps1 runs the same way +# (`Get-Content | Invoke-Expression`, as `irm | iex` does) in the PowerShell +# container: it cannot run the .exe or write the Windows registry there, so +# it checks the download, the checksum, the installed file's hash and the +# atomic replace. +# shellcheck disable=SC2016 # single-quoted test and container scripts are +# expanded later on purpose (by expect's bash, or the container's shell) +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +if [ $# -ge 1 ]; then + DIST=$1 +else + DIST=$(find "$ROOT/dist/cli" -mindepth 1 -maxdepth 1 -type d -name 'v*' | sort -V | tail -n 1) +fi +[ -f "$DIST/SHA256SUMS" ] || { echo "no release in $DIST; run scripts/release-cli.sh" >&2; exit 1; } +INSTALL_SH="$ROOT/apps/web/public/install.sh" +NET=bh-install-test +MIRROR=bh-install-mirror +# Under target/, not /tmp: /tmp is often a small tmpfs with a quota. +mkdir -p "$ROOT/target" +MIRRORS=$(mktemp -d "$ROOT/target/install-test.XXXXXX") +pass=0 +fail=0 + +cleanup() { + docker rm -f "$MIRROR" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true + rm -rf "$MIRRORS" +} +trap cleanup EXIT +cleanup + +# expect : records a pass or a failure, +# printing the output on failure. +expect() { + local name=$1 out=$2 test=$3 + if out=$out bash -c "$test"; then + echo " ok $name" + pass=$((pass + 1)) + else + echo " FAIL $name" + printf '%s\n' "$out" | while IFS= read -r line; do printf ' %s\n' "$line"; done + fail=$((fail + 1)) + fi +} + +# Mirrors: /good is the release as built; /tampered has a modified Linux +# binary under the original checksums; /unlisted has SHA256SUMS without it. +mkdir -p "$MIRRORS/good" "$MIRRORS/tampered" "$MIRRORS/unlisted" +cp "$DIST"/* "$MIRRORS/good/" +cp "$DIST"/* "$MIRRORS/tampered/" +printf 'x' >> "$MIRRORS/tampered/bridgehook-x86_64-unknown-linux-musl" +printf 'x' >> "$MIRRORS/tampered/bridgehook-x86_64-pc-windows-gnu.exe" +cp "$DIST"/* "$MIRRORS/unlisted/" +grep -v -e 'x86_64-unknown-linux-musl' -e 'x86_64-pc-windows-gnu' "$DIST/SHA256SUMS" > "$MIRRORS/unlisted/SHA256SUMS" + +# The stand-in GitHub: releases API fixtures and redirects. +VERSION=$(basename "$DIST") +VERSION=${VERSION#v} +mkdir -p "$MIRRORS/fixtures" +cat >"$MIRRORS/fixtures/releases.json" <"$MIRRORS/fixtures/releases.min.json" +NGINX_CONF="$MIRRORS/nginx.conf" +cat >"$NGINX_CONF" </dev/null +docker run -d --name "$MIRROR" --network "$NET" -v "$MIRRORS:/usr/share/nginx/html:ro" \ + -v "$NGINX_CONF:/etc/nginx/conf.d/default.conf:ro" nginx:alpine >/dev/null +for _ in $(seq 30); do + docker exec "$MIRROR" wget -qO- http://localhost/good/SHA256SUMS >/dev/null 2>&1 && break + sleep 0.5 +done + +# run . The commands are a literal script for the +# container's shell, so its variables are expanded there. +run() { + docker run --rm --network "$NET" -v "$INSTALL_SH:/install.sh:ro" \ + -e BRIDGEHOOK_DOWNLOAD_URL="http://$MIRROR/$2" -e BRIDGEHOOK_VERSION=test \ + --entrypoint sh "$1" -c "$3" 2>&1 || true +} + +# ghrun : install.sh resolving +# the version from the stand-in GitHub (no BRIDGEHOOK_DOWNLOAD_URL). +ghrun() { + docker run --rm --network "$NET" -v "$INSTALL_SH:/install.sh:ro" \ + -e BRIDGEHOOK_GITHUB_URL="http://$MIRROR/$2" -e BRIDGEHOOK_GITHUB_API_URL="http://$MIRROR/$3" \ + --entrypoint sh "$1" -c "$4" 2>&1 || true +} + +for image in rust:1-trixie nginx:alpine; do + echo "== $image" + out=$(run "$image" good 'sh /install.sh && ~/.local/bin/bridgehook --version') + expect "installs and runs" "$out" 'grep -q "^bridgehook [0-9]" <<<"$out"' + expect "PATH hint when ~/.local/bin is not on PATH" "$out" 'grep -q "is not on your PATH" <<<"$out"' + + out=$(run "$image" good 'sh /install.sh >/dev/null && sh /install.sh && ls -a ~/.local/bin') + expect "re-running upgrades in place, no staged files left" "$out" \ + 'grep -q "Installed bridgehook" <<<"$out" && ! grep -q "\.bridgehook\.new" <<<"$out"' + + out=$(run "$image" good 'mkdir -p /opt/bh && PATH=/opt/bh:$PATH BRIDGEHOOK_INSTALL_DIR=/opt/bh sh /install.sh && /opt/bh/bridgehook --version') + expect "BRIDGEHOOK_INSTALL_DIR, no hint when on PATH" "$out" \ + 'grep -q "^bridgehook [0-9]" <<<"$out" && ! grep -q "not on your PATH" <<<"$out"' + + out=$(run "$image" tampered 'sh /install.sh; echo "exit=$?"; ls ~/.local/bin 2>/dev/null | wc -l') + expect "tampered binary aborts, nothing installed" "$out" \ + 'grep -q "checksum mismatch" <<<"$out" && grep -q "exit=1" <<<"$out" && [ "$(tail -n 1 <<<"$out" | tr -d " ")" = 0 ]' + + out=$(run "$image" unlisted 'sh /install.sh; echo "exit=$?"') + expect "binary missing from SHA256SUMS aborts" "$out" \ + 'grep -q "not listed in SHA256SUMS" <<<"$out" && grep -q "exit=1" <<<"$out"' + + out=$(ghrun "$image" gh api 'sh /install.sh && ~/.local/bin/bridgehook --version') + expect "newest stable CLI release, skipping a prerelease and other releases" "$out" \ + "grep -q 'Installing bridgehook $VERSION ' <<<\"\$out\" && grep -q '^bridgehook [0-9]' <<<\"\$out\"" + out=$(ghrun "$image" gh api-min 'sh /install.sh') + expect "same from minified API JSON" "$out" "grep -q 'Installing bridgehook $VERSION ' <<<\"\$out\"" + out=$(ghrun "$image" gh api-limited 'sh /install.sh') + expect "rate-limited API falls back to the latest-release redirect" "$out" \ + "grep -q 'Installing bridgehook $VERSION ' <<<\"\$out\" && grep -q 'Installed bridgehook' <<<\"\$out\"" + out=$(ghrun "$image" gh-other api-limited 'sh /install.sh; echo "exit=$?"') + expect "no CLI release found: names BRIDGEHOOK_VERSION, installs nothing" "$out" \ + 'grep -q "BRIDGEHOOK_VERSION" <<<"$out" && grep -q "exit=1" <<<"$out"' +done + +# install.ps1 in the PowerShell container. Its non-Windows branch needs +# BRIDGEHOOK_INSTALL_DIR and skips the registry PATH and running the .exe. +PS1="$ROOT/apps/web/public/install.ps1" +WIN_SUM=$(awk '$2 == "bridgehook-x86_64-pc-windows-gnu.exe" { print $1 }' "$DIST/SHA256SUMS") +psrun() { + docker run --rm --network "$NET" -v "$PS1:/install.ps1:ro" \ + -e BRIDGEHOOK_DOWNLOAD_URL="http://$MIRROR/$1" -e BRIDGEHOOK_VERSION=test \ + -e BRIDGEHOOK_INSTALL_DIR=/tmp/bh \ + --entrypoint pwsh mcr.microsoft.com/powershell:latest -NoProfile -Command "$2" 2>&1 || true +} +IEX='Get-Content -Raw /install.ps1 | Invoke-Expression' +echo "== install.ps1 (mcr.microsoft.com/powershell)" +out=$(psrun good "$IEX; (Get-FileHash /tmp/bh/bridgehook.exe -Algorithm SHA256).Hash.ToLower()") +expect "installs bridgehook.exe with the released hash" "$out" "grep -q 'Installed bridgehook' <<<\"\$out\" && grep -qx '$WIN_SUM' <<<\"\$out\"" + +out=$(psrun good "$IEX | Out-Null; $IEX; Get-ChildItem -Name /tmp/bh") +expect "re-running upgrades in place, no .new or .old left" "$out" \ + '[ "$(grep -c "Installed bridgehook" <<<"$out")" = 2 ] && grep -qx "bridgehook.exe" <<<"$out" && ! grep -q "bridgehook.exe.new\|bridgehook.exe.old" <<<"$out"' + +out=$(psrun tampered "try { $IEX } catch { \"caught: \$_\" }; Test-Path /tmp/bh/bridgehook.exe") +expect "tampered binary aborts, nothing installed" "$out" 'grep -q "checksum mismatch" <<<"$out" && tail -n 1 <<<"$out" | grep -qx False' + +out=$(psrun unlisted "try { $IEX } catch { \"caught: \$_\" }; Test-Path /tmp/bh/bridgehook.exe") +expect "binary missing from SHA256SUMS aborts" "$out" 'grep -q "not listed in SHA256SUMS" <<<"$out" && tail -n 1 <<<"$out" | grep -qx False' + +psgh() { + docker run --rm --network "$NET" -v "$PS1:/install.ps1:ro" \ + -e BRIDGEHOOK_GITHUB_URL="http://$MIRROR/$1" -e BRIDGEHOOK_GITHUB_API_URL="http://$MIRROR/$2" \ + -e BRIDGEHOOK_INSTALL_DIR=/tmp/bh \ + --entrypoint pwsh mcr.microsoft.com/powershell:latest -NoProfile -Command "$3" 2>&1 || true +} +out=$(psgh gh api "$IEX") +expect "newest stable CLI release, skipping a prerelease and other releases" "$out" "grep -q 'Installing bridgehook $VERSION ' <<<\"\$out\" && grep -q 'Installed bridgehook' <<<\"\$out\"" +out=$(psgh gh api-limited "$IEX") +expect "rate-limited API falls back to the latest-release redirect" "$out" "grep -q 'Installing bridgehook $VERSION ' <<<\"\$out\" && grep -q 'Installed bridgehook' <<<\"\$out\"" +out=$(psgh gh-other api-limited "try { $IEX } catch { \"caught: \$_\" }; Test-Path /tmp/bh/bridgehook.exe") +expect "no CLI release found: names BRIDGEHOOK_VERSION, installs nothing" "$out" 'grep -q "BRIDGEHOOK_VERSION" <<<"$out" && tail -n 1 <<<"$out" | grep -qx False' + +out=$(docker run --rm --network none -v "$PS1:/install.ps1:ro" --entrypoint pwsh mcr.microsoft.com/powershell:latest \ + -NoProfile -Command "try { $IEX } catch { \"caught: \$_\" }" 2>&1 || true) +expect "points macOS and Linux users to install.sh" "$out" 'grep -q "install.sh" <<<"$out"' + +echo +echo "$pass passed, $fail failed" +[ "$fail" = 0 ]