diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 916cba5..2c39c2b 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -29,6 +29,92 @@ permissions: contents: read jobs: + daemon: + # A binary per platform, so installing the daemon is a download rather than a Rust + # toolchain and a C compiler. The extension is on the store now, which means people arrive + # without either. + # + # Unsigned, deliberately. Gatekeeper and SmartScreen act on a flag the *downloading + # application* attaches — `com.apple.quarantine`, Mark-of-the-Web — and `curl` and `scp` do + # not attach one. A command-line tool installed the way command-line tools are installed + # meets neither. Signing would cost a few hundred a year to remove a warning on the one + # route the README does not recommend. + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + # Every platform that can build should, even when one cannot. A macOS runner out of + # capacity is not a reason to leave Linux users with nothing. + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + os: windows-latest + exe: .exe + - target: aarch64-apple-darwin + os: macos-latest + exe: "" + - target: x86_64-apple-darwin + os: macos-latest + exe: "" + # Built on the older runner on purpose: a binary linked against a newer glibc will + # not start on an older one, and the people this is for are on login nodes somebody + # else administers. `musl` would remove the floor entirely and is not here yet, + # because `ring` wants a C toolchain targeting musl and an untested build inside a + # release job fails at the worst possible moment. + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + exe: "" + env: + TAG: ${{ inputs.tag }} + TARGET: ${{ matrix.target }} + EXE: ${{ matrix.exe }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + targets: ${{ matrix.target }} + + # `--locked` so the binary somebody downloads was built from the dependency versions in + # the tag rather than from whatever resolved on the day. + - name: build + run: cargo build --release --locked --bin ssh-browser --target ${{ matrix.target }} + + # Named for the target rather than the runner: that is the string a reader matches + # against their own machine, and it is what `cargo-binstall` looks for. + - name: name it after the platform + shell: bash + run: | + # No version in the name, and that is what makes + # `releases/latest/download/ssh-browser-` work -- the URL that never goes + # stale cannot interpolate one. Named with the version, it 404s, which is how this + # was found: by running the command the README gives rather than reading it. + # + # The version is still in the path of a pinned download, + # `releases/download/v0.5.1/...`, which is where it belongs. + out="ssh-browser-$TARGET$EXE" + cp "target/$TARGET/release/ssh-browser$EXE" "$out" + # Published so somebody can check what they downloaded against what this built, + # which is what an unsigned binary can offer instead of a signature. + # + # `sha256sum`, not `shasum`: the Git Bash on the Windows runner has the first and + # not the second, and this job was dispatched against an existing tag precisely so + # that finding out cost a failed test run rather than a failed release. + sha256sum "$out" > "$out.sha256" + cat "$out.sha256" + echo "ASSET=$out" >> "$GITHUB_ENV" + + - name: upload + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + extension: name: attach the extension runs-on: ubuntu-latest diff --git a/README.md b/README.md index efb3696..93b4247 100644 --- a/README.md +++ b/README.md @@ -157,15 +157,40 @@ that is most of them, and all of what `file://` breaks. ## Usage +Every release carries a binary per platform. One file, nothing to unpack: + +``` +curl -L https://github.com/QAtlasHub/ssh-browser/releases/latest/download/ssh-browser-x86_64-unknown-linux-gnu -o ssh-browser +chmod +x ssh-browser +./ssh-browser serve docs=myhost:/srv/docs cluster=login-node +``` + +Swap the target for `aarch64-apple-darwin`, `x86_64-apple-darwin` or +`x86_64-pc-windows-msvc.exe`. Each has a `.sha256` beside it. + +**`curl`, not your browser — and that is why it is the first thing on this page.** These +binaries are unsigned, and macOS and Windows both refuse an unsigned executable: but only one +the *browser* downloaded. Gatekeeper reads a `com.apple.quarantine` flag and SmartScreen a +Mark-of-the-Web, and both are attached by the downloading application. `curl` and `scp` attach +neither. A signature would cost a few hundred a year to remove a warning on the one route this +page does not recommend. + +With cargo, the same binary without the URL: + +``` +cargo binstall ssh-browser +``` + +Or build it, which is also what to do for a platform not in that list: + ``` cargo install ssh-browser -ssh-browser serve docs=myhost:/srv/docs cluster=login-node ``` -**What that needs on your machine:** a Rust toolchain and a C compiler. The C compiler is for -`ring`, which the TLS stack compiles — `cc` on Linux and macOS, the MSVC build tools or a MinGW -toolchain on Windows. Nothing else: no OpenSSL, no system TLS library, no daemon, no service. -Everything TLS is inside the binary, and `deny.toml` bans the OpenSSL crates so it stays that way. +**That last one needs a Rust toolchain and a C compiler.** The C compiler is for `ring`, which +the TLS stack compiles — `cc` on Linux and macOS, the MSVC build tools or a MinGW toolchain on +Windows. Nothing else: no OpenSSL, no system TLS library, no daemon, no service. Everything TLS +is inside the binary, and `deny.toml` bans the OpenSSL crates so it stays that way. At runtime it needs `ssh` on your `PATH` and nothing more. Trusting the https certificate uses a command your OS already has — `certutil`, `security`, or `update-ca-certificates` — and diff --git a/crates/ssh-browser/Cargo.toml b/crates/ssh-browser/Cargo.toml index f9676ad..f4ada17 100644 --- a/crates/ssh-browser/Cargo.toml +++ b/crates/ssh-browser/Cargo.toml @@ -12,6 +12,18 @@ keywords = ["ssh", "sftp", "browser", "proxy", "origin"] # from a shell would be neither. categories = ["network-programming", "command-line-utilities"] +# Where the built binaries are, so `cargo binstall ssh-browser` takes one instead of +# compiling. Nothing but `cargo-binstall` reads this table; cargo ignores `package.metadata`. +# +# Spelled out rather than left to its defaults, because it tries several shapes and a +# near-miss falls back to building from source — which is the C compiler this exists to avoid, +# arrived at silently. The names come from `release-assets.yml`; if one moves, both move. +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/ssh-browser-{ target }{ binary-ext }" +# Not an archive. One file is the whole program, and unpacking a tarball that contains a +# single binary is a step which exists only because most projects have more than one. +pkg-fmt = "bin" + [lints] workspace = true diff --git a/e2e/run.mjs b/e2e/run.mjs index 8325c04..b6b19e5 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -978,8 +978,17 @@ async function main() { })); check("with no daemon, the first run says what this is and what to run", () => { assert.match(firstRun.view, /daemon is not running/); - assert.match(firstRun.cmd, /cargo install ssh-browser/); - assert.match(firstRun.cmd, /ssh-browser serve/); + // A download rather than a build. Somebody arriving from the store has neither a Rust + // toolchain nor a C compiler, and `cargo install` was a wall dressed as an instruction. + assert.match(firstRun.cmd, /curl -L -o ssh-browser/); + assert.match(firstRun.cmd, /releases\/latest\/download\/ssh-browser-/); + // `.exe` on Windows, none elsewhere, so the check is about the instruction rather + // than the filename. This runs on whichever platform is running it -- souta's + // Windows and CI's Linux take different branches of the same function. + assert.match(firstRun.cmd, /ssh-browser(\.exe)? serve/); + // Not a link to the releases page: a browser download is the one thing that attaches + // the quarantine flag these unsigned binaries would then be refused for. + assert.doesNotMatch(firstRun.cmd, /^https/m); assert.ok(firstRun.repo.includes("QAtlasHub/ssh-browser"), `no source link: ${firstRun.repo}`); }); // Two versions of the same bad news, one of them in red, reads as two problems. diff --git a/extension/src/dashboard.ts b/extension/src/dashboard.ts index 560526b..791c47a 100644 --- a/extension/src/dashboard.ts +++ b/extension/src/dashboard.ts @@ -301,6 +301,38 @@ function renderList(): void { /// extension for the store, who will install it with no daemon anywhere. One red line /// naming a command they have never heard of is not enough to act on, and "does not /// function" is a fair reading of it. +/// What to run to get the daemon, for the platform reading this. +/// +/// `curl`, not a link to the releases page, and the difference is not convenience. These +/// binaries are unsigned; macOS and Windows refuse an unsigned executable, but only one the +/// *browser* downloaded — Gatekeeper reads a `com.apple.quarantine` flag and SmartScreen a +/// Mark-of-the-Web, and both are attached by whatever did the downloading. `curl` attaches +/// neither. Putting a download link here would walk somebody into the one warning the whole +/// arrangement avoids. +/// +/// The platform is a guess and says so by naming the file: somebody on an Intel Mac can see +/// `aarch64` and change it. Guessing wrong is a wrong filename, which is visible; asking would +/// be a question before the first useful thing has happened. +function installCommand(): string { + const url = (target: string) => + `https://github.com/QAtlasHub/ssh-browser/releases/latest/download/ssh-browser-${target}`; + const agent = navigator.userAgent; + if (agent.includes("Windows")) { + return [ + `curl -L -o ssh-browser.exe ${url("x86_64-pc-windows-msvc.exe")}`, + ".\\ssh-browser.exe serve", + ].join("\n"); + } + // Apple Silicon by default, because it is most Macs now and the alternative is named in the + // line above it either way. + const target = agent.includes("Mac OS X") + ? "aarch64-apple-darwin" + : "x86_64-unknown-linux-gnu"; + return [`curl -L -o ssh-browser ${url(target)}`, "chmod +x ssh-browser", "./ssh-browser serve"].join( + "\n", + ); +} + function renderOffline(firstRun: boolean): void { const view = clear(); if (!firstRun) { @@ -318,9 +350,15 @@ function renderOffline(firstRun: boolean): void { "half; the daemon runs on your own machine and does the SSH.", ), ); - view.append(node("pre", "cmd", "cargo install ssh-browser\nssh-browser serve")); + view.append(node("pre", "cmd", installCommand())); view.append( - node("p", "note", "Then reload this page. It talks to 127.0.0.1 and to nothing else."), + node( + "p", + "note", + "Then reload this page. It talks to 127.0.0.1 and to nothing else. With cargo, " + + "cargo binstall ssh-browser fetches the same binary; cargo install ssh-browser " + + "builds it, which needs a C compiler.", + ), ); const repo = document.createElement("a");