diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5253371..2a718ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,29 @@ jobs: - run: go vet ./... - run: go test ./... + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # bootstrap.sh is the only thing that ever runs on a fresh host, and nothing here can + # run it — it wants root, a package manager and k3s. Syntax plus the extracted-block + # checks in bootstrap_test.sh is the coverage that is reachable without a machine. + # Each file is parsed by the interpreter its own shebang names. A blanket `sh -n` is + # wrong and not obviously so: on a developer machine `sh` is usually bash and passes + # everything, while the runner's `sh` is dash and rejects bootstrap.sh at the first of + # its arrays. Honouring the shebang is what makes local and CI agree. + - name: Check shell syntax + run: | + for f in $(git ls-files '*.sh'); do + case "$(head -1 "$f")" in + *bash) bash -n "$f" || exit 1 ;; + *) sh -n "$f" || exit 1 ;; + esac + done + + - run: sh deploy/bootstrap_test.sh + panel: runs-on: ubuntu-latest steps: diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 7334325..adc3ec1 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -34,6 +34,10 @@ # through the handshake address instead of modern forwarding # (default: legacy18). Read once at Velocity start, so changing it # means re-running this script and restarting the proxy. +# FELIS_VELOCITY_FORK_JAR path to a Felis-Legacy Velocity fork build to install as the +# proxy instead of the stock download (default: unset, stock). +# FELIS_VELOCITY_FORK_JAR_SHA256 expected sha256 of that jar. REQUIRED whenever the jar +# above is set; the install refuses on a mismatch. # FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4) # FELIS_REPO_URL git URL to build from (raw script mode only) # FELIS_VERSION_BOOTSTRAP release|dev — which version to install (default: release). @@ -132,9 +136,20 @@ FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-3.5.1}" # # Opt-in because it is unmeasured where it counts: FL-008's probe runs offline-mode # against a stub, and this jar would carry every real Mojang session on the server. -# The build lives in Felis-Legacy and is not byte-reproducible, so there is no digest -# to pin here — the jar is trusted because that probe certified the build. FELIS_VELOCITY_FORK_JAR="${FELIS_VELOCITY_FORK_JAR:-}" +# Expected sha256 of that jar, REQUIRED whenever it is set. Case and internal spaces are +# ignored, so whatever sha256sum, Get-FileHash or certutil printed can be pasted as-is. +# No digest is hardcoded here: +# the build lives in Felis-Legacy and has never been reproduced on a second machine, so +# any constant this script carried would pin one machine's output rather than the fork. +# +# So this is not a supply-chain signature and does not pretend to be one — an operator +# who can write the jar can write this value too. What it does buy: a path is not an +# identity, and every re-run of this script re-checks it. A truncated copy, a stale build +# left at the same path, or the two-patch jar where the three-patch one was meant all +# change the digest and stop the install. Naming the digest once is what turns "whatever +# is at that path today" into one specific build. +FELIS_VELOCITY_FORK_JAR_SHA256="${FELIS_VELOCITY_FORK_JAR_SHA256:-}" # Temurin 25: Velocity 3.5 needs 21+, and 25 is also what a future Velocity 4 requires, # so the runtime does not have to move again when the pin does. Distro JDK packaging is # a lottery across four package managers — a tarball is one code path everywhere (same @@ -1471,12 +1486,31 @@ install_jre() { install_velocity() { install_jre - local url tmp + local url tmp have want prepare_velocity_layout if [ -n "$FELIS_VELOCITY_FORK_JAR" ]; then [ -f "$FELIS_VELOCITY_FORK_JAR" ] \ || die "FELIS_VELOCITY_FORK_JAR is not a readable file: ${FELIS_VELOCITY_FORK_JAR}" - log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR}" + # Hash stdin, never the path — same reason as install_via_plugins: sha256sum escapes its + # output line for a filename carrying a backslash or a newline, and the leading "\" that + # adds would fail every comparison below. + have="$(sha256sum <"$FELIS_VELOCITY_FORK_JAR" | cut -d' ' -f1)" + # Refuse rather than warn. This jar is the proxy every player connects through, and a + # warning in an install log is not a gate. The digest is printed so the first run after + # a deliberate rebuild is one copy-paste, not an investigation. + [ -n "$FELIS_VELOCITY_FORK_JAR_SHA256" ] || die \ + "FELIS_VELOCITY_FORK_JAR_SHA256 is required whenever FELIS_VELOCITY_FORK_JAR is set. + The jar at that path hashes to ${have}. + Check that against the build you meant to install, then re-run with + FELIS_VELOCITY_FORK_JAR_SHA256=${have}" + # Normalise the operator's digest before comparing. sha256sum prints lowercase, but the + # build host is often Windows, where Get-FileHash prints uppercase and certutil has + # shipped both with and without spaces between the bytes. All three name the same jar, + # so comparing raw would refuse two of the three spellings and word it as tampering. + want="$(printf '%s' "$FELIS_VELOCITY_FORK_JAR_SHA256" | tr -d '[:space:]' | tr 'A-Z' 'a-z')" + [ "$have" = "$want" ] || die \ + "FELIS_VELOCITY_FORK_JAR checksum mismatch: got ${have}, expected ${want}" + log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR} (sha256 ${have})" atomic_install_file "$FELIS_VELOCITY_FORK_JAR" "${VELOCITY_DIR}/velocity.jar" 0644 root root else log "resolving the newest Velocity ${FELIS_VELOCITY_VERSION} build" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh new file mode 100644 index 0000000..ac06448 --- /dev/null +++ b/deploy/bootstrap_test.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Checks for deploy/bootstrap.sh. Run it as: sh deploy/bootstrap_test.sh +# +# The script it tests cannot be run here — it wants root, a package manager, k3s and the +# network — so each case extracts the block it is about out of bootstrap.sh verbatim and runs +# that with die/log stubbed. Extraction rather than a transcribed copy is the point: a copy +# passes forever after someone edits the original. +set -u + +BS="${1:-$(dirname "$0")/bootstrap.sh}" +[ -f "$BS" ] || { echo "no such script: $BS"; exit 1; } +fails=0 + +expect() { # label needle haystack + case "$3" in + *"$2"*) echo "PASS $1" ;; + *) echo "FAIL $1: expected <$2> in:"; echo "$3"; fails=$((fails + 1)) ;; + esac +} + +# --- the FELIS_VELOCITY_FORK_JAR digest gate ------------------------------------------- +# This jar becomes the proxy every player connects through, so the interesting cases are the +# two refusals, not the happy path. + +gate="$(awk '/have="\$\(sha256sum <"\$FELIS_VELOCITY_FORK_JAR"/,/^ log "installing the Felis-Legacy/' "$BS")" +[ -n "$gate" ] || { echo "FAIL: no fork-jar digest gate found in $BS"; exit 1; } +# awk runs an unmatched end pattern to EOF, which would quietly pipe the rest of bootstrap.sh +# into the `sh -c` below. The emptiness check above only catches a broken start pattern. +[ "$(printf '%s\n' "$gate" | wc -l)" -lt 40 ] \ + || { echo "FAIL: the extracted block is not the gate -- did its last line move?"; exit 1; } + +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +printf 'stand-in for a fork build\n' > "$jar" +want="$(sha256sum <"$jar" | cut -d' ' -f1)" + +run_gate() { # digest + FELIS_VELOCITY_FORK_JAR="$jar" FELIS_VELOCITY_FORK_JAR_SHA256="$1" sh -c ' + die() { printf "DIE: %s\n" "$*"; exit 1; } + log() { printf "LOG: %s\n" "$*"; } + '"$gate" +} + +out="$(run_gate '')" +expect "fork jar without a digest is refused" "DIE: FELIS_VELOCITY_FORK_JAR_SHA256 is required" "$out" +expect "the refusal names the jar's real digest" "$want" "$out" + +out="$(run_gate 'deadbeef')" +expect "a mismatched digest is refused" "checksum mismatch: got ${want}, expected deadbeef" "$out" + +out="$(run_gate "$want")" +expect "the matching digest installs" "LOG: installing the Felis-Legacy Velocity fork" "$out" +expect "the install line records the digest" "(sha256 ${want})" "$out" + +# The build host is usually Windows, where nothing prints the digest the way sha256sum does: +# Get-FileHash returns uppercase and certutil has shipped the bytes space-separated. Feeding +# the gate its own output can never catch that -- these two cases are the operator's paste. +out="$(run_gate "$(printf '%s' "$want" | tr 'a-z' 'A-Z')")" +expect "an uppercase digest is the same digest" "LOG: installing the Felis-Legacy Velocity fork" "$out" + +out="$(run_gate "$(printf '%s' "$want" | sed 's/../& /g')")" +expect "a space-separated digest is the same digest" "LOG: installing the Felis-Legacy Velocity fork" "$out" + +# --------------------------------------------------------------------------------------- +if [ "$fails" -eq 0 ]; then + echo "ALL PASS" +else + echo "$fails FAILED" +fi +exit "$fails"