From 1d7a2fbd39374cbcb331d8b0c7dbe8156fd5e24a Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:50:49 +0200 Subject: [PATCH 01/41] Run the freshness monitor whenever the domain serves an index The monitor was inert while packaging/gpg.key was absent, so deleting the key later would have turned a live repository into a green, unchecked one. It is now inert only while every APT and DNF entry point answers a clean HTTP 404. Any other answer (a network or TLS error, 403, 5xx, a redirect) fails the run, and so does a served index without a readable key; the existing issue mechanism then opens or reuses the issue. The decision lives in packaging/repo/live.sh so the CI control job can test it with a local server. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/repo-freshness.yml | 35 +++++++++----- packaging/repo/live.sh | 72 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 packaging/repo/live.sh diff --git a/.github/workflows/repo-freshness.yml b/.github/workflows/repo-freshness.yml index dc1f41a..a0c66e1 100644 --- a/.github/workflows/repo-freshness.yml +++ b/.github/workflows/repo-freshness.yml @@ -7,9 +7,11 @@ name: Repository freshness # files only, uses no secret and no environment, and writes nothing but an issue labelled # `repo-freshness`: opened (or commented on) while the check fails, and closed when it passes again. # -# Until packaging/gpg.key is committed (the repositories are not live before that) it does nothing. -# From then on it fails closed: a download that fails, an unreadable release or a signature that does -# not verify is a failure, never a pass. +# It does nothing only while the domain serves no APT or DNF index (every entry point answers a clean +# 404: the repositories are not live yet). Once any index is served, packaging/gpg.key must be present +# or the run fails, so removing the key cannot silence the monitor. From then on it fails closed: a +# download that fails, an unreadable release or a signature that does not verify is a failure, never +# a pass (packaging/repo/live.sh decides which of the two applies). on: schedule: - cron: "43 6 * * 1" # Mondays, 06:43 UTC @@ -46,14 +48,23 @@ jobs: with: persist-credentials: false - - name: Check the repositories are live + # Inert only while no index is served. Any other answer than a clean 404 (a network error, a + # 403, a 5xx, a redirect) or a served index without packaging/gpg.key fails the run, and the + # report step below opens or reuses the issue. + - name: Check whether the repositories are live id: gate run: | - if [ ! -s packaging/gpg.key ]; then - echo "::notice::packaging/gpg.key is not committed yet, so the repositories are not live; nothing to check." - exit 0 - fi - echo "active=true" >> "$GITHUB_OUTPUT" + rc=0 + sh packaging/repo/live.sh "$BASE_URL" packaging/gpg.key > "$RUNNER_TEMP/report.txt" 2>&1 || rc=$? + cat "$RUNNER_TEMP/report.txt" + case $rc in + 0) echo "active=true" >> "$GITHUB_OUTPUT" ;; + 10) echo "::notice::The repositories are not live yet (no index is served); nothing to check." ;; + *) + echo "active=true" >> "$GITHUB_OUTPUT" + echo "result=fail" >> "$GITHUB_OUTPUT" + ;; + esac # GitHub disables scheduled workflows of a repository without activity for 60 days. A long gap # since the previous run is reported with the result, because a monitor that did not run @@ -89,7 +100,7 @@ jobs: # The latest published release (not a draft, not a prerelease) decides what must be served. # Anything that cannot be read is a failure. The report is what the issue shows. - name: Check the repositories serve the latest release - if: steps.gate.outputs.active == 'true' + if: steps.gate.outputs.active == 'true' && steps.gate.outputs.result != 'fail' id: check env: GH_TOKEN: ${{ github.token }} @@ -140,7 +151,7 @@ jobs: if: "!cancelled() && steps.gate.outputs.active == 'true'" env: GH_TOKEN: ${{ github.token }} - RESULT: ${{ steps.check.outputs.result }} + RESULT: ${{ steps.gate.outputs.result || steps.check.outputs.result }} EXPECTED: ${{ steps.check.outputs.expected }} GAP: ${{ steps.gap.outputs.note }} run: | @@ -176,7 +187,7 @@ jobs: { echo "The APT and DNF repositories at $BASE_URL did not pass the weekly freshness check." echo - echo "Expected version: ${EXPECTED:-unknown (the latest release could not be read)}" + echo "Expected version: ${EXPECTED:-not determined (the check stopped before reading the latest release)}" echo echo '```' print_report diff --git a/packaging/repo/live.sh b/packaging/repo/live.sh new file mode 100644 index 0000000..c8b939b --- /dev/null +++ b/packaging/repo/live.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Hodeitek S.L. +# +# Decides whether the freshness monitor has anything to check: it is inert only while the domain +# serves no APT or DNF index at all. Once an index is served, the repository key must be present, so +# deleting packaging/gpg.key can never turn the monitor green. +# +# live.sh +# +# is the root of the repositories (https://packages.hodeishield.com) and the +# path of packaging/gpg.key. The entry points are the ones freshness.sh reads: apt/dists/stable/InRelease +# and rpm//repodata/repomd.xml for x86_64 and aarch64. +# +# Environment: +# FRESHNESS_ALLOW_HTTP 1 allows a plain http base URL, which is only for testing against a local server +# FRESHNESS_RETRIES retries of a failed request (default 3) +# +# Exit status: 0 when an index is served and the key is present and readable (run freshness.sh); +# 10 when every entry point answers with a clean 404 (not live yet: nothing to check); 1 for anything +# else. Failing closed: only a plain HTTP 404 means "not there". A DNS or TLS failure, a timeout, a 403, +# a 5xx, a redirect or any other answer is a failure, and so is a served index without the key. + +set -eu + +base=$1 +key=$2 +retries=${FRESHNESS_RETRIES:-3} + +die() { + printf 'live.sh: %s\n' "$*" >&2 + exit 1 +} + +case $retries in + *[!0-9]* | '') die "FRESHNESS_RETRIES is not a number" ;; +esac +proto='=https' +if [ "${FRESHNESS_ALLOW_HTTP:-}" = 1 ]; then proto='=http,https'; fi +case $base in + http://* | https://*) ;; + *) die "the base URL is not an http(s) URL: $base" ;; +esac +base=${base%/} +stamp=$(date +%s) + +served=0 +missing=0 +for path in apt/dists/stable/InRelease rpm/x86_64/repodata/repomd.xml rpm/aarch64/repodata/repomd.xml; do + # No --fail: the status itself is the answer. Redirects are not followed, so one is not a 404. + code=$(curl --silent --show-error --proto "$proto" --connect-timeout 10 --max-time 60 \ + --retry "$retries" --retry-delay 2 \ + --header 'Cache-Control: no-cache' --header 'Pragma: no-cache' \ + --output /dev/null --write-out '%{http_code}' "$base/$path?nocache=$stamp") || + die "cannot reach $base/$path" + case $code in + 404) missing=$((missing + 1)) ;; + 200) served=$((served + 1)) ;; + *) die "$base/$path answered HTTP $code, which is neither a served index nor a clean 404" ;; + esac +done + +if [ "$served" = 0 ]; then + printf 'live.sh: %s serves no APT or DNF index yet (%s of 3 entry points answer 404); nothing to check.\n' \ + "$base" "$missing" + exit 10 +fi + +printf 'live.sh: %s serves an index (%s of 3 entry points).\n' "$base" "$served" +[ -f "$key" ] && [ -r "$key" ] && [ -s "$key" ] || + die "the repositories are live but the public key $key is missing, empty or unreadable" +exit 0 From b8c916bee20c360af114f4c25419a48c32f2b3da Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:51:42 +0200 Subject: [PATCH 02/41] Test the live switch of the freshness monitor in CI The control job now also covers the cases that decide whether the monitor runs at all: a domain that serves nothing is inert, while a served index with the key missing, empty or unreadable, and any fetch error that is not a clean 404 (500, 403, a redirect, a refused connection), are red. A silent monitor must never be the result of a deleted key or a broken fetch, so each of these is proven to fail. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +- packaging/repo/freshness-test.sh | 80 +++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9aadc5c..777e925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,7 +233,9 @@ jobs: # The repository freshness monitor must go red: its control builds small repositories with a # throwaway key, serves them from localhost and checks that every stale, altered, unsigned or - # missing case fails and the current one passes. + # missing case fails and the current one passes. It also checks the live switch: inert only when + # the domain serves nothing (all 404); a live repository without the key, or a fetch error that is + # not a 404, is red. repo-freshness: name: repo-freshness runs-on: ubuntu-latest diff --git a/packaging/repo/freshness-test.sh b/packaging/repo/freshness-test.sh index b07533d..12ac39e 100644 --- a/packaging/repo/freshness-test.sh +++ b/packaging/repo/freshness-test.sh @@ -2,10 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Hodeitek S.L. # -# Proves that freshness.sh goes red: builds small APT and DNF repositories with a throwaway key made +# Proves that freshness.sh goes red, and that live.sh (which decides whether there is anything to +# check) is inert only when the domain serves nothing: builds small APT and DNF repositories with a throwaway key made # here (nothing secret, nothing real), serves them from localhost and runs freshness.sh against them. # A repository that is up to date must pass, and every case in which it is stale, altered, unsigned, # missing or unreachable must fail, with the status expected for it (3 for stale, 1 for the rest). +# For live.sh: no index at all (every entry point a clean 404) is inert (10); a fetch error that is not +# a 404 (500, a redirect, a refused connection) and a served index without the key are red (1). # # freshness-test.sh # @@ -23,9 +26,11 @@ mkdir -m 700 "$GNUPGHOME" www=$work/www mkdir "$www" server= +servers= cleanup() { [ -z "$server" ] || kill "$server" 2> /dev/null || true + for pid in $servers; do kill "$pid" 2> /dev/null || true; done gpgconf --homedir "$GNUPGHOME" --kill all 2> /dev/null || true # The throwaway secret keys are overwritten before they are removed. find "$GNUPGHOME" -type f -exec shred -u {} + 2> /dev/null || true @@ -182,6 +187,47 @@ done [ -n "$port" ] || die "the test server did not start" url=http://127.0.0.1:$port +# A server that answers every request with the status (and a redirect to nowhere for 301). +# start_status sets $status_url. +start_status() { + : > "$work/status.log" + python3 -I -u -c ' +import http.server, sys +code = int(sys.argv[1]) +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(code) + if code == 301: + self.send_header("Location", "http://127.0.0.1:1/") + self.send_header("Content-Length", "0") + self.end_headers() + def log_message(self, *args): + pass +s = http.server.HTTPServer(("127.0.0.1", 0), H) +print(s.server_address[1], flush=True) +s.serve_forever()' "$1" > "$work/status.log" & + servers="$servers $!" + sp= + i=0 + while [ -z "$sp" ] && [ "$i" -lt 50 ]; do + sp=$(sed -n '1p' "$work/status.log") + i=$((i + 1)) + [ -n "$sp" ] || sleep 0.1 + done + [ -n "$sp" ] || die "the status server did not start" + status_url=http://127.0.0.1:$sp +} +start_status 500 +url500=$status_url +start_status 403 +url403=$status_url +start_status 301 +url301=$status_url + +# Only the APT repository is served: the DNF entry points answer 404. +cp -R "$www/fresh" "$www/apt-only" +rm -r "$www/apt-only/rpm" + # --- The cases --------------------------------------------------------------------------------------- FRESHNESS_ALLOW_HTTP=1 FRESHNESS_RETRIES=0 @@ -219,5 +265,37 @@ check 'server unreachable' 1 http://127.0.0.1:1 0.10.0 "$work/repo.asc" check 'no public key' 1 "$url/fresh" 0.10.0 "$work/missing.asc" FRESHNESS_ALLOW_HTTP=0 check 'plain http not allowed' 1 "$url/fresh" 0.10.0 "$work/repo.asc" + +# checklive [] +checklive() { + status=0 + sh "$here/live.sh" "$3" "$4" > "$work/out" 2>&1 || status=$? + if [ "$status" = "$2" ] && { [ -z "${5:-}" ] || grep -Fq -- "$5" "$work/out"; }; then result=ok; else result=WRONG; bad=1; fi + printf '%-34s %-6s %-6s %-6s %s\n' "$1" "$2" "$status" "$result" "$(sed -n 's/^live.sh: //p' "$work/out" | head -n 1)" + [ "$result" = ok ] || sed 's/^/ /' "$work/out" +} + +FRESHNESS_ALLOW_HTTP=1 +printf '\n%-34s %-6s %-6s %-6s %s\n' 'LIVE CASE' WANT GOT RESULT WHY +# Nothing served: inert, with or without a key. +checklive 'domain serves nothing (all 404)' 10 "$url/nowhere" "$work/repo.asc" +checklive 'serves nothing, no key' 10 "$url/nowhere" "$work/missing.asc" +# An index is served. +checklive 'live repo, key present' 0 "$url/fresh" "$work/repo.asc" +checklive 'live repo, key missing' 1 "$url/fresh" "$work/missing.asc" 'the repositories are live but the public key' +: > "$work/empty.asc" +checklive 'live repo, key empty' 1 "$url/fresh" "$work/empty.asc" 'the repositories are live but the public key' +mkdir "$work/keydir" +checklive 'live repo, key is a directory' 1 "$url/fresh" "$work/keydir" +checklive 'only APT served, key missing' 1 "$url/apt-only" "$work/missing.asc" +checklive 'only APT served, key present' 0 "$url/apt-only" "$work/repo.asc" +# Anything but a clean 404 is red, whether or not the key is there. +checklive 'fetch error: HTTP 500' 1 "$url500" "$work/repo.asc" 'answered HTTP 500' +checklive 'fetch error: HTTP 500, no key' 1 "$url500" "$work/missing.asc" 'answered HTTP 500' +checklive 'fetch error: HTTP 403' 1 "$url403" "$work/missing.asc" +checklive 'fetch error: redirect' 1 "$url301" "$work/missing.asc" +checklive 'fetch error: refused' 1 http://127.0.0.1:1 "$work/missing.asc" 'cannot reach' +FRESHNESS_ALLOW_HTTP=0 +checklive 'plain http not allowed' 1 "$url/nowhere" "$work/repo.asc" [ "$bad" = 0 ] || exit 1 echo "All cases behave as expected." From e6508b73b890ca84488b262dccef280cdf818e25 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:52:44 +0200 Subject: [PATCH 03/41] Document when the freshness monitor is inert CONTRIBUTING said the monitor waits for packaging/gpg.key. It now waits only for the domain to serve an index, and a served index without the key is a failure, which maintainers need to know when reading a repo-freshness issue. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c28ddc..d344663 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,9 +161,13 @@ ASCII-armored secret signing subkey only), and the matching public key committed The APT and DNF indexes carry no `Valid-Until`, so an older index that was once validly signed and is served again from a compromised bucket would still verify: the weekly `repo-freshness` workflow detects -this (once `packaging/gpg.key` is committed), it does not prevent it. It checks that both repositories +this, it does not prevent it. It checks that both repositories are signed by that key and that the newest `hodeishield` version in each index is the latest published release's (`packaging/repo/freshness.sh`, with `packaging/repo/freshness-test.sh` as its control in CI). +The monitor is inert only while the domain serves no APT or DNF index, that is, while `InRelease` and each +`repomd.xml` answer a clean 404 (`packaging/repo/live.sh`). Any other answer (a network or TLS error, 403, +5xx, a redirect), or a served index while `packaging/gpg.key` is missing or unreadable, fails the run, so +removing the key cannot silence it. A failure opens an issue labelled `repo-freshness` with what was expected, what is served and the run, and it is closed when the check passes again; a release less than 48 hours old that is not served yet only raises a warning, since the `repos` job waits for an approval. On such an issue, first check who From fa0491b5eac1716bd1789073eac70f656aae7180 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:52:44 +0200 Subject: [PATCH 04/41] Document installing from the APT and DNF repositories The README and the site page now give the full setup for Debian and Ubuntu (a keyring file with signed-by, after checking the key's fingerprint) and for Fedora, RHEL, Rocky Linux and AlmaLinux (the .repo file, with the DNF 4 and DNF 5 commands), plus upgrading and the one-line key refresh for when the signing key's expiry is extended. The README and the changelog carry @@PACKAGES_KEY_FINGERPRINT@@ until the key is committed; the site page gets the fingerprint from the key when the repository is built. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 7 ++++ README.md | 74 ++++++++++++++++++++++++++++++++++++--- packaging/repo/index.html | 29 +++++++++++---- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b5e03e..110a414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +### Added + +- Signed APT and DNF repositories at `packages.hodeishield.com` for x86_64 and arm64, so `apt` and + `dnf` install and upgrade the CLI. The repositories are signed with a key whose fingerprint is + `@@PACKAGES_KEY_FINGERPRINT@@`; the README has the setup steps for Debian and Ubuntu, and for Fedora, + RHEL, Rocky Linux and AlmaLinux (DNF 4 and 5). + ### Fixed - Terminal output cleaning now also replaces the line and paragraph separators (U+2028, U+2029), diff --git a/README.md b/README.md index 698dbe6..9f58c41 100644 --- a/README.md +++ b/README.md @@ -83,17 +83,81 @@ waiting for review by the winget community repository; it is not available yet. ### Debian, Ubuntu, Fedora, RHEL and other Linux distributions -From 0.3.0, each release also publishes `.deb` and `.rpm` packages for x86_64 and arm64. They install `/usr/bin/hodeishield`, the man pages and the bash, zsh and fish completions. -Verify the package as in the next section (it has its own `.sigstore.json` and is listed in -`SHA256SUMS`), then: +Signed APT and DNF repositories at `packages.hodeishield.com` provide `.deb` and `.rpm` packages for +x86_64 and arm64, and your system's normal upgrades keep the CLI current. The packages install +`/usr/bin/hodeishield`, the man pages and the bash, zsh and fish completions. + +The repositories are signed with this key (RSA 4096); check its fingerprint before trusting it: + +```text +@@PACKAGES_KEY_FINGERPRINT@@ +``` + +#### Debian, Ubuntu (APT) + +```sh +sudo install -d -m 0755 /etc/apt/keyrings +curl -fsSL https://packages.hodeishield.com/gpg.key -o hodeishield.asc +gpg --show-keys --with-fingerprint hodeishield.asc # must show the fingerprint above +sudo install -m 0644 hodeishield.asc /etc/apt/keyrings/hodeishield.asc && rm hodeishield.asc +echo "deb [signed-by=/etc/apt/keyrings/hodeishield.asc] https://packages.hodeishield.com/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/hodeishield.list +sudo apt update && sudo apt install hodeishield +``` + +Do not install the key into `/etc/apt/trusted.gpg.d`: with `signed-by`, it is trusted for this repository +only. Upgrade with `sudo apt update && sudo apt upgrade` (or `sudo apt install --only-upgrade hodeishield`). + +#### Fedora, RHEL, Rocky Linux, AlmaLinux (DNF) + +Add the repository with the `.repo` file, which turns on signature checking of both the packages and the +repository metadata (`gpgcheck=1`, `repo_gpgcheck=1`). The command depends on your DNF version: + +```sh +# DNF 5 (Fedora 41 and later) +sudo dnf config-manager addrepo --from-repofile=https://packages.hodeishield.com/rpm/hodeishield.repo + +# DNF 4 (RHEL, Rocky Linux and AlmaLinux 8 and 9, older Fedora; needs the dnf-plugins-core package) +sudo dnf config-manager --add-repo https://packages.hodeishield.com/rpm/hodeishield.repo + +# Either one, without the plugin +sudo curl -fsSLo /etc/yum.repos.d/hodeishield.repo https://packages.hodeishield.com/rpm/hodeishield.repo +``` + +Then install. The first time, DNF shows the key and asks to import it: confirm only if the fingerprint +is the one above. + +```sh +sudo dnf install hodeishield +``` + +Upgrade with `sudo dnf upgrade hodeishield`. `sudo apt remove hodeishield` or +`sudo dnf remove hodeishield` uninstalls it. + +#### When the repository key is renewed + +The signing key has an expiry date and is extended before it passes. The fingerprint stays the same, so +only your copy of the public key needs refreshing. If `apt update` or `dnf` reports an expired key, run: + +```sh +# Debian, Ubuntu: compare the fingerprint with the one above first (as in the install steps) +curl -fsSL https://packages.hodeishield.com/gpg.key | sudo tee /etc/apt/keyrings/hodeishield.asc > /dev/null + +# Fedora, RHEL, Rocky Linux, AlmaLinux +sudo rpm --import https://packages.hodeishield.com/gpg.key +``` + +#### A single package file + +Each release also attaches the `.deb` and `.rpm` packages (from 0.3.0). Verify the package as in the next +section (it has its own `.sigstore.json` and is listed in `SHA256SUMS`), then: ```sh sudo apt install ./hodeishield_-1_amd64.deb # Debian, Ubuntu (arm64: _arm64.deb) sudo dnf install ./hodeishield--1.x86_64.rpm # Fedora, RHEL (arm64: .aarch64.rpm) ``` -Upgrading is the same command with the newer package; `sudo apt remove hodeishield` or -`sudo dnf remove hodeishield` uninstalls it. +Upgrading is the same command with the newer package. ### Windows installer (MSI) diff --git a/packaging/repo/index.html b/packaging/repo/index.html index 08f42f5..7901fea 100644 --- a/packaging/repo/index.html +++ b/packaging/repo/index.html @@ -21,17 +21,32 @@

HodeiShield CLI packages

Debian, Ubuntu

sudo install -d -m 0755 /etc/apt/keyrings
-curl -fsSL https://packages.hodeishield.com/gpg.key | sudo tee /etc/apt/keyrings/hodeishield.asc > /dev/null
-gpg --show-keys /etc/apt/keyrings/hodeishield.asc     # compare the fingerprint with the one above
+curl -fsSL https://packages.hodeishield.com/gpg.key -o hodeishield.asc
+gpg --show-keys --with-fingerprint hodeishield.asc     # compare the fingerprint with the one above
+sudo install -m 0644 hodeishield.asc /etc/apt/keyrings/hodeishield.asc && rm hodeishield.asc
 echo "deb [signed-by=/etc/apt/keyrings/hodeishield.asc] https://packages.hodeishield.com/apt stable main" \
   | sudo tee /etc/apt/sources.list.d/hodeishield.list
 sudo apt update && sudo apt install hodeishield
-

Fedora, RHEL, Rocky, AlmaLinux

-
sudo curl -fsSLo /etc/yum.repos.d/hodeishield.repo https://packages.hodeishield.com/rpm/hodeishield.repo
-sudo dnf install hodeishield     # dnf shows the key's fingerprint before importing it
+

Fedora, RHEL, Rocky Linux, AlmaLinux

+
# DNF 5 (Fedora 41 and later)
+sudo dnf config-manager addrepo --from-repofile=https://packages.hodeishield.com/rpm/hodeishield.repo
+# DNF 4 (RHEL, Rocky Linux, AlmaLinux 8 and 9; needs dnf-plugins-core)
+sudo dnf config-manager --add-repo https://packages.hodeishield.com/rpm/hodeishield.repo
+# or, with either version
+sudo curl -fsSLo /etc/yum.repos.d/hodeishield.repo https://packages.hodeishield.com/rpm/hodeishield.repo
 
-

Updates arrive with your system's normal upgrades. Release notes, other platforms and how to verify -each release are in the CLI repository.

+sudo dnf install hodeishield # dnf shows the key's fingerprint before importing it: compare it with the one above
+ +

Updates and key renewal

+

Updates arrive with your system's normal upgrades. The signing key is extended before it expires and +keeps the same fingerprint; if your system reports an expired key, refresh your copy:

+
# Debian, Ubuntu (compare the fingerprint first, as above)
+curl -fsSL https://packages.hodeishield.com/gpg.key | sudo tee /etc/apt/keyrings/hodeishield.asc > /dev/null
+# Fedora, RHEL, Rocky Linux, AlmaLinux
+sudo rpm --import https://packages.hodeishield.com/gpg.key
+ +

Release notes, other platforms and how to verify each release are in the +CLI repository.

From 80dfbbf48bf2d829093407c8ef286f745341459e Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:26:37 +0200 Subject: [PATCH 05/41] Accept only good signatures from the repository key, and warn before it expires gpgv exits 0 for a signature made by an expired or a revoked key and for an expired signature; it reports EXPKEYSIG, REVKEYSIG or EXPSIG on the status fd only. build.sh's self-check and freshness.sh looked at the exit code alone, so such an index would have verified. Both now read the status lines through one shared helper and require a GOODSIG and a VALIDSIG made by the repository key's primary key, and fail on EXPKEYSIG, REVKEYSIG, EXPSIG, BADSIG or ERRSIG. freshness.sh also reads the expiry of the primary key and of its usable signing subkeys from packaging/gpg.key and exits 5 when either expires within 60 days. The weekly workflow turns that into a warning and an issue, not a red run, so the renewal (which needs a release) is not left until clients start to fail. The control job gains throwaway keys made with a faked clock: a signing subkey that has expired, a revoked one, and keys that expire within 60 days. The first two also check that gpgv itself exits 0 for them, so the cases keep proving what they claim. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/repo-freshness.yml | 29 +++++++++- packaging/repo/build.sh | 13 +++-- packaging/repo/freshness-test.sh | 85 +++++++++++++++++++++++++++- packaging/repo/freshness.sh | 43 +++++++++++++- packaging/repo/sigcheck.sh | 27 +++++++++ 5 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 packaging/repo/sigcheck.sh diff --git a/.github/workflows/repo-freshness.yml b/.github/workflows/repo-freshness.yml index a0c66e1..bbd22ca 100644 --- a/.github/workflows/repo-freshness.yml +++ b/.github/workflows/repo-freshness.yml @@ -143,6 +143,10 @@ jobs: result=grace echo "::warning::The latest release is less than $GRACE_HOURS hours old and the repositories do not serve it yet; not treated as a failure." ;; + 5) + result=warn + echo "::warning::The repository key, or its signing subkey, expires within 60 days; renew it (CONTRIBUTING.md)." + ;; *) result=fail ;; esac echo "result=$result" >> "$GITHUB_OUTPUT" @@ -177,6 +181,29 @@ jobs: issue=$(gh issue list -R "$GITHUB_REPOSITORY" --label "$LABEL" --state open --json number --jq '.[0].number // empty') case ${RESULT:-fail} in + warn) + { + echo "The APT and DNF repositories at $BASE_URL are current, but the repository key or its signing subkey expires within 60 days." + echo + echo "Renew it before then and publish a release: see \"The repository signing key\" in CONTRIBUTING.md. Clients that still hold the old copy reject signatures made after the expiry." + echo + echo '```' + print_report + echo '```' + echo + echo "Run: $run_url" + } > "$RUNNER_TEMP/issue.md" + if [ -n "$issue" ]; then + gh issue comment "$issue" -R "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/issue.md" + else + gh label list -R "$GITHUB_REPOSITORY" --search "$LABEL" --json name --jq '.[].name' | grep -qx "$LABEL" || + gh label create "$LABEL" -R "$GITHUB_REPOSITORY" --color D93F0B \ + --description "The APT or DNF repository is not serving the latest release" + gh issue create -R "$GITHUB_REPOSITORY" --label "$LABEL" \ + --title "The repository signing key expires within 60 days" \ + --body-file "$RUNNER_TEMP/issue.md" + fi + ;; pass) if [ -n "$issue" ]; then gh issue close "$issue" -R "$GITHUB_REPOSITORY" --reason completed \ @@ -210,7 +237,7 @@ jobs: esac - name: Fail if the check failed - if: "!cancelled() && steps.gate.outputs.active == 'true' && steps.check.outputs.result != 'pass' && steps.check.outputs.result != 'grace'" + if: "!cancelled() && steps.gate.outputs.active == 'true' && steps.check.outputs.result != 'pass' && steps.check.outputs.result != 'grace' && steps.check.outputs.result != 'warn'" run: | echo "::error::The APT and DNF repositories did not pass the freshness check; see the issue labelled repo-freshness." exit 1 diff --git a/packaging/repo/build.sh b/packaging/repo/build.sh index fb213a0..aefca93 100644 --- a/packaging/repo/build.sh +++ b/packaging/repo/build.sh @@ -41,6 +41,9 @@ check=$(mktemp -d) trap 'rm -rf "$rpmdb" "$check"' EXIT rpm --dbpath "$rpmdb" --import "$PUBLIC_KEY" gpg --batch --quiet --dearmor --output "$check/key.gpg" "$PUBLIC_KEY" +primary=$(gpg --batch --with-colons --show-keys "$PUBLIC_KEY" | awk -F: '$1 == "fpr" { print $10; exit }') +# shellcheck source=sigcheck.sh +. "$here/sigcheck.sh" die() { printf 'build.sh: %s\n' "$*" >&2 @@ -116,7 +119,7 @@ if [ -e "$published/InRelease" ]; then }; then die "the published InRelease is not a single signed message" fi - gpgv --quiet --keyring "$check/key.gpg" --output "$check/Release" "$published/InRelease" 2> /dev/null || + sig_ok "$check/key.gpg" "$primary" --output "$check/Release" "$published/InRelease" || die "the published InRelease does not verify" for arch in amd64 arm64; do packages=$published/main/binary-$arch/Packages @@ -242,8 +245,8 @@ fingerprint=$(gpg --batch --with-colons --show-keys "$PUBLIC_KEY" | awk -F: '$1 sed "s/@FINGERPRINT@/$fingerprint/g" "$here/index.html" > "$repo/index.html" # --- Self-check before anything is uploaded --------------------------------------------------------- -gpgv --quiet --keyring "$check/key.gpg" "$dists/InRelease" 2> /dev/null || die "InRelease does not verify" -gpgv --quiet --keyring "$check/key.gpg" "$dists/Release.gpg" "$dists/Release" 2> /dev/null || +sig_ok "$check/key.gpg" "$primary" "$dists/InRelease" || die "InRelease does not verify" +sig_ok "$check/key.gpg" "$primary" "$dists/Release.gpg" "$dists/Release" || die "Release.gpg does not verify" # Every index that Release lists must be there, with its by-hash copies (apt-ftparchive makes none for # the per-architecture Release files, whose content never changes). apt asks for the strongest hash. @@ -268,8 +271,8 @@ for arch in amd64 arm64; do fi done for arch in x86_64 aarch64; do - gpgv --quiet --keyring "$check/key.gpg" "$repo/rpm/$arch/repodata/repomd.xml.asc" \ - "$repo/rpm/$arch/repodata/repomd.xml" 2> /dev/null || die "repomd.xml ($arch) does not verify" + sig_ok "$check/key.gpg" "$primary" "$repo/rpm/$arch/repodata/repomd.xml.asc" \ + "$repo/rpm/$arch/repodata/repomd.xml" || die "repomd.xml ($arch) does not verify" for rpm in "$repo/rpm/$arch"/*.rpm; do [ -e "$rpm" ] || continue rpm_signed "$rpm" || die "$(basename "$rpm") has no valid signature" diff --git a/packaging/repo/freshness-test.sh b/packaging/repo/freshness-test.sh index 12ac39e..fac6ac7 100644 --- a/packaging/repo/freshness-test.sh +++ b/packaging/repo/freshness-test.sh @@ -12,6 +12,9 @@ # # freshness-test.sh # +# The signature cases use throwaway keys made with gpg's faked system time: one whose signing subkey +# has expired, one whose signing subkey is revoked, and two that expire within 60 days (a warning, 5). +# # Needs curl, gpg, gpgv, gzip, python3 and the tools freshness.sh needs. The indexes are written by # hand in the structure that apt-ftparchive and createrepo_c produce (the repository builder's own # output is checked by build.sh when the repositories are published). @@ -54,6 +57,45 @@ other_fpr=$(gpg --batch --with-colons --list-keys other@example.invalid | awk -F gpg --batch --quiet --armor --export "$other_fpr" > "$work/other.asc" signer=$repo_sub +# gpg with a faked clock when $faked is set (a time as YYYYMMDDTHHMMSS), for keys that have to be +# expired now but valid when they signed. +faked= +gpgt() { + if [ -n "$faked" ]; then gpg --faked-system-time "$faked" "$@"; else gpg "$@"; fi +} +# newkey