diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18da89938..8d0ddcffa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,9 +205,10 @@ jobs: - name: Publish install scripts run: | cp src/packaging/install-linux.sh pages/install-linux.sh + cp src/packaging/install-linux-binary.sh pages/install-linux-binary.sh cp src/packaging/install-mac.sh pages/install-mac.sh cp src/packaging/install-windows.ps1 pages/install-windows.ps1 - chmod +x pages/install-linux.sh pages/install-mac.sh + chmod +x pages/install-linux.sh pages/install-linux-binary.sh pages/install-mac.sh - name: Write self-update checksum files run: | @@ -323,6 +324,10 @@ jobs: Linuxrpm / deb / apk curl -fsSL https://buckit-io.github.io/buckit/install-linux.sh | sh + + Linuxstandalone binary + curl -fsSL https://buckit-io.github.io/buckit/install-linux-binary.sh | sh + macOSApple Silicon curl -fsSL https://buckit-io.github.io/buckit/install-mac.sh | sh diff --git a/README.md b/README.md index 95b793760..a1ade6cc9 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,17 @@ SHA-256 checksum, and prints the package-manager command to run: curl -fsSL https://buckit-io.github.io/buckit/install-linux.sh | sh ``` +### Linux Standalone Binary + +To run the server without a package or a systemd service — for example when +taking over an existing deployment that is started by hand — download the +binary on its own. The helper script verifies its SHA-256 checksum and leaves +an executable `buckit` in the current directory: + +```sh +curl -fsSL https://buckit-io.github.io/buckit/install-linux-binary.sh | sh +``` + ### Build From Source Buckit requires Go 1.25 or newer. If Go is not installed, download and install diff --git a/packaging/install-linux-binary.sh b/packaging/install-linux-binary.sh new file mode 100755 index 000000000..7bc5bc6b0 --- /dev/null +++ b/packaging/install-linux-binary.sh @@ -0,0 +1,221 @@ +#!/bin/sh +# install-linux-binary.sh — Linux standalone-binary installer helper for buckit. +# +# Downloads the Linux buckit binary for the latest stable release to a +# predictable filename (buckit), verifies its published SHA-256 checksum, and +# leaves it in place so you can run it directly. It does NOT install it onto +# your PATH, and it does NOT register a systemd service. +# +# For a package-managed install with a systemd service, use install-linux.sh +# instead, which downloads the .deb/.rpm/.apk for this host. +# +# Usage: +# curl -fsSL https://buckit-io.github.io/buckit/install-linux-binary.sh | sh +# ./buckit --help # run it from where it was downloaded +# +# Environment overrides: +# BUCKIT_PAGES_BASE gh-pages base URL +# (default: https://buckit-io.github.io/buckit) +# BUCKIT_RELEASE_BASE release download base +# (default: https://github.com/buckit-io/buckit/releases/download) +# BUCKIT_VERSION pin a release tag (e.g. RELEASE.2026-05-11T17-20-40Z) +# instead of resolving the latest stable release +# BUCKIT_DOWNLOAD_DIR directory to download the binary into +# (default: the current directory) + +set -eu + +PAGES_BASE="${BUCKIT_PAGES_BASE:-https://buckit-io.github.io/buckit}" +RELEASE_BASE="${BUCKIT_RELEASE_BASE:-https://github.com/buckit-io/buckit/releases/download}" + +# Set by resolve_release. +TAG="" +POINTER_SHA="" + +err() { + echo "install-linux-binary.sh: $*" >&2 + exit 1 +} + +info() { + echo "==> $*" +} + +# detect_platform requires Linux and sets ARCH to the Go-style arch tuple +# (amd64 / arm64) matching the published release assets. +detect_platform() { + os="$(uname -s)" + [ "$os" = "Linux" ] || err "this installer is for Linux (detected '$os'). On macOS use install-mac.sh; on Windows use install-windows.ps1" + + arch="$(uname -m)" + case "$arch" in + x86_64 | amd64) ARCH="amd64" ;; + aarch64 | arm64) ARCH="arm64" ;; + *) err "unsupported architecture '$arch'. Only amd64 and arm64 builds are published." ;; + esac +} + +# validate_tag rejects anything that is not a plain release identifier. Both +# the pinned BUCKIT_VERSION and the pointer-resolved tag go through this, so a +# value like '../../evil' cannot reach the download URL as path traversal. +validate_tag() { + case "$1" in + RELEASE.?*) ;; + *) err "unexpected release tag '$1' (expected RELEASE.*)" ;; + esac + # Only the characters real release tags use. Rejects '/', whitespace, + # control characters, and URL delimiters such as '?' and '#'. + case "$1" in + *[!A-Za-z0-9._-]*) err "release tag '$1' contains unsupported characters" ;; + esac +} + +# normalize_sha lowercases a hex digest and requires exactly 64 hex characters, +# so a truncated or malformed checksum record can never be compared as if it +# were a valid digest. Echoes the normalized digest. +normalize_sha() { + _sha="$(printf '%s' "$1" | tr 'ABCDEF' 'abcdef')" + case "$_sha" in + "" | *[!0-9a-f]*) err "malformed sha256 digest: '$1'" ;; + esac + [ "${#_sha}" -eq 64 ] || err "malformed sha256 digest: '$1'" + printf '%s' "$_sha" +} + +# fetch URL -> stdout +fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$1" + else + err "need curl or wget to download" + fi +} + +# fetch_to URL FILE +fetch_to() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + err "need curl or wget to download" + fi +} + +# sha256_of FILE -> normalized hex digest on stdout. The hash utility runs on +# its own rather than inside a pipeline, so a failure surfaces instead of being +# masked by the exit status of a downstream parser. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + _hash_out="$(sha256sum "$1")" || err "sha256sum failed on $1" + elif command -v shasum >/dev/null 2>&1; then + _hash_out="$(shasum -a 256 "$1")" || err "shasum failed on $1" + else + err "need sha256sum or shasum to verify the download" + fi + normalize_sha "$(printf '%s\n' "$_hash_out" | awk 'NR==1{print $1}')" +} + +# resolve_release sets TAG, and POINTER_SHA when the release was resolved from +# the gh-pages pointer. A pinned BUCKIT_VERSION leaves POINTER_SHA empty: the +# pointer only ever describes the latest release, so it cannot vouch for an +# arbitrary pinned version. +resolve_release() { + if [ -n "${BUCKIT_VERSION:-}" ]; then + validate_tag "$BUCKIT_VERSION" + TAG="$BUCKIT_VERSION" + POINTER_SHA="" + return + fi + + pointer_url="$PAGES_BASE/server/buckit/release/linux-$ARCH/buckit.sha256sum" + # Pointer format: " buckit." + pointer="$(fetch "$pointer_url")" || err "could not fetch release pointer at $pointer_url" + + name="$(printf '%s\n' "$pointer" | awk 'NR==1{print $2}')" + case "$name" in + buckit.*) ;; + *) err "unexpected release pointer payload: $pointer" ;; + esac + + TAG="${name#buckit.}" + validate_tag "$TAG" + POINTER_SHA="$(normalize_sha "$(printf '%s\n' "$pointer" | awk 'NR==1{print $1}')")" +} + +main() { + detect_platform + info "platform: linux-$ARCH" + + resolve_release + [ -n "$TAG" ] || err "could not resolve a release tag" + info "release: $TAG" + + asset="buckit-linux-$ARCH.$TAG" + download_url="$RELEASE_BASE/$TAG/$asset" + + dldir="${BUCKIT_DOWNLOAD_DIR:-.}" + mkdir -p "$dldir" + binfile="$dldir/buckit" + + # Refuse to run when the destination is a directory. 'mv' would move the + # temp file inside it and the script would report success while leaving + # nothing runnable at the path it prints. + [ ! -d "$binfile" ] || err "$binfile is a directory — remove it or set BUCKIT_DOWNLOAD_DIR" + + # Download to a temporary sibling and only move it into the predictable + # path after the checksum verifies, so a failed or interrupted download + # can never clobber an existing good binary or leave a partial/unverified + # file at the path the printed command references. + tmpfile="$(mktemp "$dldir/.buckit.XXXXXX")" || err "could not create temp file in $dldir" + trap 'rm -f "$tmpfile"' EXIT + + info "downloading $asset" + fetch_to "$download_url" "$tmpfile" || err "download failed: $download_url" + + info "fetching published checksum" + # Capture the payload first: piping the fetch straight into a parser would + # hide a failed transfer behind the parser's exit status. + checksum_payload="$(fetch "$download_url.sha256sum")" || + err "could not fetch checksum at $download_url.sha256sum" + release_sha="$(normalize_sha "$(printf '%s\n' "$checksum_payload" | awk 'NR==1{print $1}')")" + + # The binary and the checksum beside it come from the same origin, so that + # digest alone only proves the download was not corrupted in transit. The + # gh-pages pointer publishes the same digest from a separate origin; + # when it is available, require the two to agree before trusting either. + if [ -n "$POINTER_SHA" ]; then + if [ "$POINTER_SHA" != "$release_sha" ]; then + err "published digests disagree (pages $POINTER_SHA, release $release_sha) — refusing to continue" + fi + want_sha="$POINTER_SHA" + info "sha256 cross-checked against the release pointer" + else + want_sha="$release_sha" + fi + + got_sha="$(sha256_of "$tmpfile")" + if [ "$got_sha" != "$want_sha" ]; then + err "checksum mismatch (expected $want_sha, got $got_sha) — refusing to continue" + fi + info "sha256 verified" + + chmod 0755 "$tmpfile" + + mv -f "$tmpfile" "$binfile" + trap - EXIT + + echo + echo "Downloaded and verified:" + echo " $binfile" + echo + echo "Run it from here:" + echo " \"$binfile\"" + echo + echo "(Move it onto your PATH to call 'buckit' from anywhere.)" + echo +} + +main "$@"