diff --git a/README.md b/README.md index f21be96..39b7b9c 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,15 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman ### With apt (Debian/Ubuntu — recommended) -The package is published to the Debian registry of the forge itself. The -`heavy-duty` organization is private, so installation requires a Forgejo user -that belongs to the organization and a personal access token that can read its -packages. One-time setup: +The package is published to the public Debian registry of the forge itself. +One-time setup: + +```bash +curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash +``` + +If a private registry or a `FORGE_URL=` override requires authentication, +download the installer and supply a Forgejo login and package-readable token: ```bash export FORGE_USER=your-forgejo-login @@ -27,15 +32,16 @@ sudo --preserve-env=FORGE_USER,FORGE_TOKEN bash /tmp/stoke-install-apt.sh unset FORGE_TOKEN ``` -The installer keeps the credentials out of the source URL in a root-readable -apt auth file. To configure that file manually before adding the source: +The authenticated path keeps credentials out of the source URL in a +root-readable apt auth file. To configure that file manually before adding the +source: ```bash sudo install -d -m 0755 /etc/apt/auth.conf.d +sudo install -m 0600 /dev/null /etc/apt/auth.conf.d/forgejo-heavy-duty.conf printf 'machine forgejo.heavyduty.builders\nlogin %s\npassword %s\n' \ "$FORGE_USER" "$FORGE_TOKEN" \ | sudo tee /etc/apt/auth.conf.d/forgejo-heavy-duty.conf >/dev/null -sudo chmod 0600 /etc/apt/auth.conf.d/forgejo-heavy-duty.conf ``` Then add the forge's registry as an apt source: @@ -77,11 +83,13 @@ run: ```bash export STOKE_ALLOW_UNVERIFIED_APT=1 -sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT \ - bash /tmp/stoke-install-apt.sh +curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash unset STOKE_ALLOW_UNVERIFIED_APT ``` +For a private registry, re-run the downloaded installer with +`sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT` instead. + This exact opt-in is the only path in the installer that writes a `[trusted=yes]` source. The installer prints the security trade-off again when it takes that path. diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index 8da2021..caea0e9 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -52,10 +52,10 @@ if [ -n "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; then forge_host="${FORGE_URL#*://}" forge_host="${forge_host%%/*}" $SUDO install -d -m 0755 "$APT_ETC/auth.conf.d" + $SUDO install -m 0600 /dev/null "$AUTH" printf 'machine %s\nlogin %s\npassword %s\n' \ "$forge_host" "$FORGE_USER" "$FORGE_TOKEN" \ | $SUDO tee "$AUTH" >/dev/null - $SUDO chmod 0600 "$AUTH" CURL_AUTH=(--netrc-file "$AUTH") fi @@ -108,7 +108,7 @@ ensure_nodejs_source() { } echo "Adding APT source for $FORGE_URL/$OWNER ..." -$SUDO install -d -m 0755 "$APT_ETC/keyrings" +$SUDO install -d -m 0755 "$APT_ETC/keyrings" "$APT_ETC/sources.list.d" $SUDO curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \ | $SUDO tee "$LIST" >/dev/null @@ -134,19 +134,13 @@ fi # is not the key algorithm, because the registry serves an RSA-2048 signing # key (`gpg --list-packets` on repository.key reports `algo 1` with a # 2048-bit pkey[0]). Try the properly signed source first so this heals -# automatically once the forge is fixed. Only that signature-error class, plus +# automatically once the forge is fixed. Only the exact live sqv failure, plus # the user's exact opt-in, permits an unverified source; auth, network, and # other failures must leave verification enabled and retain apt's diagnostic. if update_output="$(update_only_source "$LIST" 2>&1)"; then printf '%s\n' "$update_output" else update_status=$? - if ! grep -Eiq \ - 'NO_PUBKEY|EXPKEYSIG|BADSIG|signatures? (could not|couldn.t) be verified|signature (verification )?(failed|failure|error|invalid)|repository .*not signed|is not signed' \ - <<<"$update_output"; then - printf '%s\n' "$update_output" >&2 - exit "$update_status" - fi if ! grep -Fqi '/usr/bin/sqv' <<<"$update_output" \ || ! grep -Fqi 'Malformed MPI' <<<"$update_output"; then printf '%s\n' "$update_output" >&2 diff --git a/test/install-apt.test.js b/test/install-apt.test.js index 04ef646..c007802 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -16,12 +16,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); // sourceUpdateError stderr and exit 100 for the first signed stoke update // forgeUser/token private-registry credentials // allowUnverified explicit HTTPS-only integrity opt-in +// precreateSourcesDir whether the throwaway apt root already has sources.list.d // The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, // so every scenario doubles as a regression test for locale-safe parsing. const cleanups = []; process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); -function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified }) { +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified, precreateSourcesDir = true }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); cleanups.push(root); const bin = path.join(root, 'bin'); @@ -29,7 +30,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi const aptEtc = path.join(root, 'etc', 'apt'); fs.mkdirSync(bin, { recursive: true }); fs.mkdirSync(state, { recursive: true }); - fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true }); + if (precreateSourcesDir) fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true }); fs.writeFileSync(path.join(state, 'candidate'), candInitial); if (preexistingNodesourceList !== undefined) { fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList); @@ -43,6 +44,20 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi // Force the non-root path so every mutation goes through the sudo stub. stub('id', 'echo 1000'); stub('sudo', 'SUDO_ACTIVE=1 exec "$@"'); + stub('tee', [ + 'for destination in "$@"; do', + ' case "$destination" in', + ' */auth.conf.d/forgejo-*.conf)', + ' mode="$(stat -c %a "$destination" 2>/dev/null || true)"', + ' if [ "$mode" != 600 ]; then', + ' echo "credential destination was not mode 0600 before write" >&2', + ' exit 78', + ' fi', + ' ;;', + ' esac', + 'done', + 'exec /usr/bin/tee "$@"', + ].join('\n')); // Registry Release-file probes (URLs under /dists/) answer with the // scenario's HTTP status; everything else is a key fetch. stub('curl', [ @@ -196,6 +211,16 @@ test('registry Release file present: proceeds with the install', () => { assert.match(s.aptGetLog, /install -y stoke/); }); +test('fresh apt root creates sources.list.d before writing the forge source', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + precreateSourcesDir: false, + }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.match(s.forgeList, /\[signed-by=/); + assert.match(s.aptGetLog, /install -y stoke/); +}); + test('signature verification failure refuses by default and removes the forge source', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1', @@ -235,6 +260,20 @@ test('opt-in cannot bypass a missing signing key', () => { assert.doesNotMatch(s.aptGetLog, /install -y stoke/); }); +test('opt-in cannot bypass sqv output without the known Malformed MPI failure', () => { + const failure = 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1): unexpected packet'; + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + sourceUpdateError: failure, + allowUnverified: '1', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(s.forgeList, /\[signed-by=/); + assert.doesNotMatch(s.forgeList, /trusted=yes/); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); + test('unrecognized opt-in value is rejected before configuring apt', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1',