diff --git a/.github/workflows/package-linux.yml b/.github/workflows/package-linux.yml index 0c4db44e..7a7ff22d 100644 --- a/.github/workflows/package-linux.yml +++ b/.github/workflows/package-linux.yml @@ -199,6 +199,44 @@ jobs: done rm -rf "$UNPACK" + # The RPM is packaged from the binaries the .deb shipped, so all three + # Linux artifacts carry the same objects and the floor check that has + # already passed on the .deb covers them. The script runs rpmbuild in the + # rpm image declared in packaging/build-floor.env and checks the glibc + # requirement of the package it produced; it is the same script a local + # `make rpm` calls, which is what keeps the two identical. + - name: Build RPM package + id: rpm + shell: bash + run: | + set -euo pipefail + : ${GITHUB_OUTPUT:=/tmp/github_output} + + packaging/rpm/build-rpm-container.sh \ + --no-build \ + --version "${{ needs.determine-versioning.outputs.linux_package_version }}" \ + --output-dir deploy \ + | tee /tmp/build-rpm.log + + # The script prints the package path as its last line of stdout; its + # diagnostics go to stderr, as with build-deb-container.sh. + RPM_FILE=$(tail -n 1 /tmp/build-rpm.log) + if [[ ! -f "$RPM_FILE" ]]; then + echo "build-rpm-container.sh did not name a package: '$RPM_FILE'" >&2 + exit 1 + fi + case "$RPM_FILE" in + *.${{ matrix.artifact_arch }}.rpm) ;; + *) + echo "Package $RPM_FILE is not ${{ matrix.artifact_arch }}" >&2 + exit 1 + ;; + esac + + # Recorded relative to the checkout, like the .deb: upload-artifact + # derives its layout from the common ancestor of its paths. + echo "rpm=${RPM_FILE#"$PWD"/}" >> "$GITHUB_OUTPUT" + - name: Build systemd tarball env: STRIP: llvm-strip @@ -240,13 +278,15 @@ jobs: echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" echo "deb=${{ steps.deb.outputs.deb }}" >> "$GITHUB_OUTPUT" + echo "rpm=${{ steps.rpm.outputs.rpm }}" >> "$GITHUB_OUTPUT" - name: SHA-256 hashes run: | echo "==> Linux release assets:" sha256sum \ "${{ steps.linux-assets.outputs.tarball }}" \ - "${{ steps.linux-assets.outputs.deb }}" + "${{ steps.linux-assets.outputs.deb }}" \ + "${{ steps.linux-assets.outputs.rpm }}" - name: Upload artifact (GitHub only) if: ${{ env.ACT != 'true' }} @@ -256,6 +296,7 @@ jobs: path: | ${{ steps.linux-assets.outputs.tarball }} ${{ steps.linux-assets.outputs.deb }} + ${{ steps.linux-assets.outputs.rpm }} retention-days: 30 - name: Build Summary @@ -263,6 +304,7 @@ jobs: echo "Build Summary for linux/${{ matrix.artifact_arch }}:" echo " Tarball: ${{ steps.linux-assets.outputs.tarball }}" echo " Debian: ${{ steps.linux-assets.outputs.deb }}" + echo " RPM: ${{ steps.linux-assets.outputs.rpm }}" release: name: Publish Linux assets to GitHub Release @@ -282,7 +324,7 @@ jobs: - name: Generate Linux release checksums run: | cd dist - find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.tar.gz' \) -printf '%P\n' \ + find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.rpm' -o -name '*.tar.gz' \) -printf '%P\n' \ | LC_ALL=C sort \ | xargs sha256sum \ > checksums-linux.txt @@ -308,6 +350,7 @@ jobs: run: | gh release upload "${GITHUB_REF_NAME}" \ dist/*.deb \ + dist/*.rpm \ dist/*.tar.gz \ dist/checksums-linux.txt \ --clobber \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 540f027a..d93bb102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Packaging +- An RPM package for Fedora and RHEL (`packaging/rpm/`, + `make -C packaging rpm`), built from the same binaries the `.deb` and the + systemd tarball carry and attached to each release beside them. The package + is named `fips-mesh`, not `fips`: Fedora's namespace already has a `fips` — + an unrelated OpenGL FITS image viewer at 3.4.0 — which owns `/usr/bin/fips`, + so a `fips` at 0.6.0 is an older release of that one to every RPM tool, and + an ordinary `dnf upgrade` replaces a running mesh node with an image viewer. + The spec declares `Conflicts: fips`, since both ship `/usr/bin/fips`. + `make rpm` compiles nothing on the host: it builds in the pinned image by way + of `build-deb-container.sh`, which has already run the glibc floor and + Depends checks, and packages what that produced; `rpmbuild` itself runs in + `FIPS_RPM_BUILD_IMAGE` (AlmaLinux 9, pinned by digest), which supplies the + `systemd-rpm-macros` a build host may lack and writes packages every newer + rpm can read. `make rpm-host` remains for iteration, as `deb-host` does. + `testing/check-rpm-floor.sh` reads the glibc requirement rpm derived out of + the finished package — the table `dnf` enforces at install time — and fails a + build above the declared floor. Erase keeps `/etc/fips`: rpm has no purge, so + the code that removes a node's identity keys on a dpkg purge would run on an + ordinary erase, including the one a distribution upgrade performs. + - A pfSense package (`packaging/pfsense/`, `gmake pfsense`). pfSense is FreeBSD underneath, but the FreeBSD package fails there in three silent ways: pfSense runs only `/usr/local/etc/rc.d/*.sh` at boot and @@ -328,6 +348,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 binder tearing down and rebinding every second while teardown silently declined to abort anything. +#### Packaging + +- The glibc floor is 2.34, one step below the 2.35 Ubuntu 22.04 sets. Both + families ship the same binaries, so the project-wide floor is the lowest + supported member of either, and that is RHEL 9 and its rebuilds. + `packaging/build-floor.env` now records the RPM family alongside the Debian + one, and records RHEL 8 as a deliberate exclusion: its glibc is 2.28, and + reaching it means a second build image and a second floor. `FIPS_BUILD_IMAGE` + is still the oldest Debian-family distribution, which is no longer the oldest + distribution outright. + #### Packaging (Debian) - An upgrade of the `.deb` now reapplies the firewall ruleset in place. Until diff --git a/README.md b/README.md index cb775e49..40ad68c2 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ default `/etc/fips/fips.yaml` you can edit before starting. The package enables `fips` and `fips-dns` but starts neither, which is why the second command is there. +On Fedora or RHEL, download `fips-mesh--.x86_64.rpm` (or +`.aarch64.rpm`) and install it: + +```bash +sudo dnf install ./fips-mesh--.x86_64.rpm +sudo systemctl start fips fips-dns +``` + +The package is `fips-mesh` because Fedora's `fips` is an unrelated FITS image +viewer that owns `/usr/bin/fips`; the two conflict and dnf will say so. + +It carries the same binaries as the `.deb` — built in the same pinned +container, checked against the same glibc floor — and leaves the same +post-install state. No install-test suite covers it, and it does not +delete `/etc/fips` when removed, because rpm has no purge; +[packaging/README.md](packaging/README.md) has the full list of what it +does and does not share with the `.deb`. + For macOS, Windows, FreeBSD (including a pfSense build under `packaging/pfsense/`), OpenWrt, the systemd tarball or a Nix flake, see [docs/getting-started.md](docs/getting-started.md) @@ -200,7 +218,10 @@ and Android. Linux is not one target. Debian, Ubuntu, Arch and NixOS are the same glibc build, and what differs is the packaging: Debian and Ubuntu take the same `.deb`, Arch takes `fips` from the AUR, and NixOS uses the Nix flake described -below. **Only the `.deb` is exercised by an install test**, by the +below, and Fedora and RHEL take the `.rpm` built from the same binaries by +`packaging/rpm/`. RPM-based distributions have no column of +their own for the same reason pfSense does not: the build is the glibc +one and only the packaging differs. **Only the `.deb` is exercised by an install test**, by the `deb-install` suite across debian12, debian13, ubuntu22, ubuntu24 and ubuntu26; neither the AUR package nor the flake is. That suite runs on every push and pull request, on x86_64, against a `.deb` built by the same diff --git a/docs/getting-started.md b/docs/getting-started.md index 879754c1..cae5cef0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,6 +59,8 @@ The most direct path. The release distribution carries a per-platform installer: - Debian/Ubuntu: `.deb` package +- Fedora/RHEL: `.rpm` package, named `fips-mesh` (Fedora's `fips` is an + unrelated FITS image viewer) - Arch Linux: `fips` AUR package - OpenWrt: `.ipk` and `.apk` packages - macOS: `.pkg` installer @@ -66,13 +68,16 @@ per-platform installer: - Windows: `.zip` with service-install scripts - Generic systemd Linux: `.tar.gz` with an `install.sh` script -The `.deb` and the systemd tarball support every version of a glibc -distribution that its vendor still supports for free: currently Ubuntu -22.04, Debian 12, Ubuntu 24.04, Debian 13 and Ubuntu 26.04. Those binaries -are built in a container pinned to the oldest of them, so they run on all -five, and the glibc floor that follows is declared in -`packaging/build-floor.env` and checked by `testing/check-glibc-floor.sh` on -what the release workflow produces. Arch and NixOS build from source on your +The `.deb`, the `.rpm` and the systemd tarball carry the same binaries and +support every version of a glibc distribution that its vendor still supports +for free: currently Ubuntu 22.04, Debian 12, Ubuntu 24.04, Debian 13 and +Ubuntu 26.04 on the Debian side, and RHEL 9 and later on the RPM side. Those +binaries are built in a container pinned to the oldest Debian-family member, +and the floor they are held to is the lowest of either family — RHEL 9's glibc +2.34 — declared in `packaging/build-floor.env` and checked by +`testing/check-glibc-floor.sh` on what the release workflow produces. The +`.rpm` also records that floor as an ordinary dependency, so a package built +above it is refused rather than installed. Arch and NixOS build from source on your own machine, and OpenWrt is a musl target rather than glibc, so none of them depends on that floor. diff --git a/packaging/Makefile b/packaging/Makefile index b0cfd75a..861b3d39 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -6,6 +6,8 @@ # Usage: # make deb Build a Debian/Ubuntu .deb package in the pinned container # make deb-host Build a .deb with the host toolchain (see below) +# make rpm Build an .rpm in the pinned container +# make rpm-host Build an .rpm with the host toolchain (see below) # make tarball Build a systemd install tarball # make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier) # make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+) @@ -21,7 +23,7 @@ SHELL := /bin/bash PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) -.PHONY: all deb deb-host tarball ipk apk aur pkg freebsd pfsense zip clean +.PHONY: all deb deb-host rpm rpm-host tarball ipk apk aur pkg freebsd pfsense zip clean all: deb tarball @@ -40,6 +42,25 @@ deb: deb-host: @bash $(PACKAGING_DIR)/debian/build-deb.sh +# `rpm` compiles nothing on the host either: it builds the binaries in the same +# pinned container the .deb uses, packages those, and then checks the glibc +# requirement rpm derived for the finished package against the declared floor. +# So the RPM carries the same objects as the .deb and the tarball, and a +# package built above the floor fails here rather than at a user's `dnf +# install` -- which is what `deb` gets from its container and its Depends +# check. +rpm: + @bash $(PACKAGING_DIR)/rpm/build-rpm-container.sh + +# `rpm-host` packages whatever the host toolchain built, and like `deb-host` it +# is for local iteration and NOT for anything anyone else installs. Nothing +# checks its floor, deliberately, so the check stays attached to the artifact +# that ships. Its failure is at least loud: rpm derives the requirement from +# the binaries, so a package built on a host above the floor is refused by dnf +# on an older system rather than installed and unable to start. +rpm-host: + @bash $(PACKAGING_DIR)/rpm/build-rpm.sh + tarball: @bash $(PACKAGING_DIR)/systemd/build-tarball.sh diff --git a/packaging/README.md b/packaging/README.md index 64045732..29634e6e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -8,6 +8,7 @@ and `make apk` write to `dist/` instead. ```sh make deb # Debian/Ubuntu .deb (built in the pinned container) +make rpm # Fedora/RHEL .rpm, named fips-mesh (built in the pinned container) make tarball # systemd install tarball make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier) make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+) @@ -22,9 +23,11 @@ make all # deb + tarball (default) ## The two Debian build paths `make deb` builds in a container pinned to the oldest supported -distribution, named with the glibc floor in +Debian-family distribution, named with the glibc floor in [build-floor.env](build-floor.env), and checks the package it produced -against that floor before handing it back. Its only host prerequisite is +against that floor before handing it back. The floor itself is lower than that +image's glibc: RHEL 9 is the lowest supported distribution project-wide, and +both families ship these same binaries. Its only host prerequisite is docker: the toolchain and the build dependencies live in the image. This is the path the release workflow, the integration suite and the internal builder all take, so a package that passes locally is built the way the @@ -74,6 +77,8 @@ packaging/ common/ Shared assets (default config, hosts file) and pkg-lib.sh, the helpers the FreeBSD and pfSense builders share debian/ Debian/Ubuntu .deb packaging via cargo-deb + rpm/ Fedora/RHEL .rpm packaging via rpmbuild, over the binaries + the Debian container build produces freebsd/ FreeBSD .pkg packaging via pkg-create(8) pfsense/ pfSense .pkg packaging (FreeBSD-based, but not the same) macos/ macOS .pkg installer via pkgbuild @@ -118,6 +123,124 @@ sudo dpkg -r fips sudo dpkg -P fips ``` +### RPM (`.rpm`) + +Built with `rpmbuild` from [rpm/fips.spec](rpm/fips.spec). The same files land +in the same places as the `.deb`, the same `fips` system group is created, the +same `/etc/fips/fips.yaml` seeding happens, and the same two units are enabled; +`fips-firewall` and `fips-gateway` stay opt-in. + +**The package is named `fips-mesh`, not `fips`.** Fedora's namespace already +has a `fips` — an unrelated OpenGL FITS image viewer, currently 3.4.0 — which +owns `/usr/bin/fips`. Ours at 0.6.0 would be an *older* `fips` to every RPM +tool, so a routine `dnf upgrade` replaces a running mesh node with an image +viewer and takes the units with it; that is not hypothetical, it happened +within the hour on a test machine. The two cannot coexist either, since both +ship `/usr/bin/fips`, so the spec declares `Conflicts: fips` and dnf refuses +with both names on screen instead of a bare path. + +Like the Debian package, it has two build paths, and for the same reason. + +`make rpm` compiles nothing on the host: it builds the binaries in the image +declared in [build-floor.env](build-floor.env), packages those, and checks the +glibc requirement of the finished package against the declared floor. So the +RPM carries the same objects as the `.deb` and the tarball, and a package built +above the floor fails there rather than at a user's `dnf install`. rpmbuild +runs in `FIPS_RPM_BUILD_IMAGE` (AlmaLinux 9, pinned by digest), which supplies +two things a build host may lack: rpmbuild itself, and systemd-rpm-macros, +without which the spec's `%systemd_post` would not expand and the package would +ship scriptlets that quietly do nothing. Docker is the only host prerequisite. + +`make rpm-host` packages whatever the host toolchain built. Like `deb-host` it +is for local iteration and not for anything anyone else installs; nothing +checks its floor. + +The release workflow calls the same container script both matrix legs, with +`--no-build`, over the binaries it has already recovered from the `.deb` — one +build, three artifacts — and attaches the result to the GitHub Release next to +the `.deb` and the tarball. + +```sh +# Build (requires docker) +make rpm + +# Install +sudo dnf install ./deploy/fips-mesh--..rpm + +# Remove (keeps /etc/fips, including identity keys) +sudo dnf remove fips-mesh +``` + +Two firewalls, on the distributions where firewalld owns nftables. They do not +conflict — firewalld manages its own tables and `fips-firewall.service` adds +`table inet fips`, which returns immediately for anything not arriving on +`fips0` — but firewalld is filtering the node whether or not that unit ever +runs, and in two places worth knowing: + +- **Inbound peers arrive on your ordinary interface**, on the transport ports + (`2121/udp` and `8443/tcp` in the shipped config), and those are in whatever + zone that interface belongs to. Fedora Workstation's default zone opens + `1025-65535` for both protocols, so it works there untouched; RHEL, CentOS + Stream and Fedora Server default to `public`, which allows `ssh`, + `dhcpv6-client` and `mdns` and nothing else, so a node there accepts no + inbound peers until the ports are opened: + + ```sh + sudo firewall-cmd --permanent --add-port=2121/udp --add-port=8443/tcp + sudo firewall-cmd --reload + ``` + +- **`fips0` itself lands in the default zone**, since nothing assigns it one — + `firewall-cmd --get-zone-of-interface=fips0` says `no zone`, which means the + default. Mesh traffic to local services is then subject to that zone as well + as to the fips baseline. Giving the interface its own zone keeps the two + decisions apart, and `trusted` leaves the filtering to `/etc/fips/fips.nft` + and its drop-ins, which is where it is meant to be: + + ```sh + sudo firewall-cmd --permanent --zone=trusted --change-interface=fips0 + sudo firewall-cmd --reload + ``` + +Either way the fips table stays invisible to firewalld: `firewall-cmd +--list-all` will not show it, and opening a port with `firewall-cmd` does not +open it in the fips table. That is what `/etc/fips/fips.d/` is for. + +Note also that a default RHEL, CentOS Stream or AlmaLinux install has no +resolver backend `fips-dns-setup` can use: systemd is older than the +`dns-delegate` drop-in, systemd-resolved is installed but not enabled, and +dnsmasq is not installed. The script falls through to its last branch and +prints manual instructions, so `.fips` names do not resolve until a backend is +in place. Fedora, which enables systemd-resolved, is configured automatically. + +Three things differ from the Debian package, because the package managers do: + +- **The floor is checked on the package, not against it.** `cargo-deb` writes a + dependency floor that can disagree with the binaries, so + `testing/check-deb-depends.sh` compares the two. rpm derives the requirement + from the ELF files and cannot disagree with them, which moves the risk one + step back — to binaries built above the floor in the first place. + `testing/check-rpm-floor.sh` reads `libc.so.6(GLIBC_x.y)` out of the finished + package, the same table `dnf` enforces at install time, and fails the build + above the floor. +- **No purge.** dpkg distinguishes remove from purge, and `postrm purge` + deletes `/etc/fips` and the `fips` group. rpm has no such distinction, so the + equivalent would run on an ordinary erase — and during a distribution upgrade + that erases and reinstalls — taking the node's identity keys with it. + Configuration and keys therefore survive `dnf remove`; delete `/etc/fips` + yourself if you mean it. +- **Version vs Release.** A dev build is `0.6.0-0.dev.git.` rather + than the `.deb`'s `0.6.0~dev+git.-1`. rpm has understood `~` since + 4.10, so this is a choice rather than a limitation: a Release beginning with + `0.` is the convention for pre-release packages in this ecosystem, and it + sorts below the `1` a tagged release carries. The Release carries no `%{dist}` tag + either: there is one build, the glibc one, and a dist tag would name whichever + image happened to run rpmbuild in an artifact that installs on all of them. + +No install-test suite covers the RPM. The `deb-install` suite exercises the +`.deb` across five distributions on every push; the RPM is built on every push +and installed by nobody but you. + ### systemd Tarball A self-contained tarball with binaries and an `install.sh` script for diff --git a/packaging/build-floor.env b/packaging/build-floor.env index 08be956c..6f11b923 100644 --- a/packaging/build-floor.env +++ b/packaging/build-floor.env @@ -1,8 +1,10 @@ # The glibc floor for the Linux release artifacts, and the image that produces it. # -# Sourced by packaging/debian/build-deb-container.sh and by -# testing/check-glibc-floor.sh. It exists so the floor is a decision written -# down in one place rather than a side effect of whichever build host ran last. +# Sourced by packaging/debian/build-deb-container.sh, +# packaging/rpm/build-rpm-container.sh, testing/check-glibc-floor.sh and +# testing/check-rpm-floor.sh. It exists so the floor is a decision written +# down in one place rather than a side effect of whichever build host ran +# last. # # The rule it encodes: FIPS installs on every version of a supported operating # system that its distributor still supports for free. As of 2026-09-05 that is @@ -13,8 +15,31 @@ # Debian 13 glibc 2.41 LTS ends 2030-06-30 # Ubuntu 26.04 glibc 2.43 # -# so the lowest is Ubuntu 22.04 and the floor is its 2.35. Debian 11 left the -# set on 2026-08-31 and is deliberately not counted. +# and on the RPM side, where the same binaries ship as fips-mesh: +# +# RHEL 9 and rebuilds glibc 2.34 AlmaLinux/Rocky 9 supported to 2032-05 +# Fedora (current two) glibc 2.42+ each release supported ~13 months +# +# so the lowest of both families is RHEL 9 and the floor is its 2.34. Debian 11 +# left the set on 2026-08-31 and is deliberately not counted. openSUSE is not +# in the set yet: nobody has run the package on Leap, and it joins when an +# install leg does. +# +# RHEL 8 and its rebuilds are deliberately NOT in the set. Their glibc is 2.28 +# and AlmaLinux 8 and Rocky 8 are in free support until 2029-05, so this is a +# real exclusion rather than an oversight: reaching 2.28 means building on an +# EL8-era toolchain, which is a second build image and a second floor, and no +# one has asked for it. If someone does, that is the decision to reopen -- not +# this number. +# +# The RPM family is why the floor is 2.34 rather than Ubuntu 22.04's 2.35: both +# families ship the same binaries, so the project-wide floor is the lowest +# member of either, and that is RHEL 9. The binaries built in FIPS_BUILD_IMAGE +# happen to reference nothing above 2.34 today, which is what lets a package +# built there install on RHEL 9 at all; without this line that is luck, and the +# first commit to pull in a 2.35 symbol would pass the check and silently drop +# every EL9 host. One step tighter costs the Debian side nothing and makes the +# EL9 claim enforced. # # Deliberately NOT a GitHub runner label. Runner availability follows GitHub's # rule of supporting the newest two images; the floor follows distributors' @@ -26,10 +51,36 @@ # Changing FIPS_GLIBC_FLOOR drops support for every distribution below it. Check # the table above first, and expect check-glibc-floor.sh to hold you to it. -# Base image for the build. Pinned to the oldest supported distribution. +# Base image for the build. The oldest supported *Debian-family* distribution, +# which is no longer the oldest supported distribution outright: RHEL 9 sits a +# step below it at 2.34, and FIPS_GLIBC_FLOOR rather than this image is what +# the binaries are held to. FIPS_BUILD_IMAGE="ubuntu:22.04" +# Image that runs rpmbuild. It compiles nothing -- the binaries it packages are +# built in FIPS_BUILD_IMAGE -- and supplies two things the build host may not +# have: rpmbuild itself, and systemd-rpm-macros, without which the spec's +# %systemd_post would not expand and the package would ship scriptlets that +# quietly do nothing. The oldest rpm in free support (RHEL 9, rpm 4.16), so a +# package it writes is readable by every newer rpm, which is this file's glibc +# rule applied to the packaging format. +# +# Pinned by digest, not by tag. `almalinux:9` floats: it moves with every +# rebuild, and the systemd-rpm-macros it carries is what expands %systemd_post +# into the scriptlets a release artifact ships. A floating input to a release +# artifact is the thing this file exists to prevent, and the workflow that +# consumes it runs on three branches, every pull request and every tag. The +# digest is a multi-arch index, so both matrix legs resolve their own +# architecture from it. +# +# To move it: `docker buildx imagetools inspect almalinux:9 --format +# '{{.Manifest.Digest}}'`, and check that the rpm inside is still old enough +# for the distributions in the table above. +FIPS_RPM_BUILD_IMAGE="almalinux@sha256:3a3fa7f043b142bc8008c8b308d39b47d2c84008addcd52f9f9a7a82d2a90474" + # Highest glibc symbol version any shipped binary may require. Building on -# FIPS_BUILD_IMAGE currently yields 2.34, one step below this, so there is a -# little headroom: the check is an upper bound, not an equality. -FIPS_GLIBC_FLOOR="2.35" +# FIPS_BUILD_IMAGE currently yields exactly this, so there is no headroom left: +# the check is an upper bound, and the build sits on it. A change that raises +# what the binaries need will fail check-glibc-floor.sh rather than ship a +# package RHEL 9 refuses. +FIPS_GLIBC_FLOOR="2.34" diff --git a/packaging/rpm/build-rpm-container.sh b/packaging/rpm/build-rpm-container.sh new file mode 100755 index 00000000..6f685085 --- /dev/null +++ b/packaging/rpm/build-rpm-container.sh @@ -0,0 +1,265 @@ +#!/bin/bash +# Build the RPM from binaries compiled in the pinned container, then check the +# floor of the package it produced. +# +# This is the supported path, and the counterpart of +# packaging/debian/build-deb-container.sh. Both exist for the same reason: a +# package built against the host's C library carries that library's version +# floor, and the host is almost never the oldest system the package has to +# install on. The Debian package answered that with a pinned build image; this +# reuses that image rather than pinning a second one, so the RPM ships the same +# objects the .deb and the tarball do. +# +# rpmbuild itself runs in FIPS_RPM_BUILD_IMAGE, which compiles nothing. It is +# there because the build host may have no rpmbuild at all, and -- the part +# that would fail quietly -- may have no systemd-rpm-macros, without which the +# spec's %systemd_post does not expand and the package ships scriptlets that do +# nothing. +# +# Usage: build-rpm-container.sh [--output-dir DIR] [--version V] [--features L] +# [--no-build] [--bin-dir DIR] +# +# --no-build packages the binaries already under target/release instead of +# building any, for a caller that has them: the release workflow recovers them +# from the .deb it just built, and building them twice would only be slower. +# --bin-dir says where those binaries are, if not target/release. +# +# --features reaches cargo through the Debian container build and then marks +# the Release, so a feature build of a commit is a different package from the +# default build of the same commit. +# +# Requires docker. Nothing else: no rust toolchain, no rpmbuild, no dpkg. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=../build-floor.env +. "$REPO_ROOT/packaging/build-floor.env" +# shellcheck source=SCRIPTDIR/../../testing/lib/image-build.sh +. "$REPO_ROOT/testing/lib/image-build.sh" + +DEST_DIR="$REPO_ROOT/deploy" +VERSION="" +FEATURES="" +NO_BUILD=0 +BIN_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --output-dir) DEST_DIR="${2:?missing value for --output-dir}"; shift 2 ;; + --version) VERSION="${2:?missing value for --version}"; shift 2 ;; + --features) FEATURES="${2:?missing value for --features}"; shift 2 ;; + --no-build) NO_BUILD=1; shift ;; + --bin-dir) BIN_DIR="${2:?missing value for --bin-dir}"; NO_BUILD=1; shift 2 ;; + -h | --help) sed -n '2,28p' "$0"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac +done + +command -v docker >/dev/null 2>&1 || { + echo "build-rpm-container: docker is required and was not found." >&2 + exit 2 +} + +# The same refusal build-rpm.sh makes, and for the same reason: with no build +# of our own the features cannot reach cargo, so the Release marking below +# would claim binaries that were compiled by somebody else, with who knows +# what. Refused here rather than after the container build that --no-build was +# asked to skip. +if [ -n "$FEATURES" ] && [ "$NO_BUILD" -eq 1 ]; then + echo "build-rpm-container: --features cannot be combined with --no-build or" >&2 + echo "--bin-dir: the features would not reach the binaries, but the Release" >&2 + echo "would claim they had." >&2 + exit 2 +fi + +mkdir -p "$DEST_DIR" +DEST_ABS="$(cd "$DEST_DIR" && pwd)" + +# Both scratch directories live inside the output directory rather than under +# /tmp, which is the shape build-deb-container.sh takes and for the same +# reason: a bind-mount source is resolved by the Docker daemon in the host's +# mount namespace, so under a private /tmp -- systemd's PrivateTmp=, which the +# CI worker sets -- a path from a bare `mktemp -d` exists only in this +# process's namespace. The daemon would create its own directory at that path +# in the host's /tmp, the container would write there, and this script would +# read an empty one. The output directory is already bind-mounted as /out and +# so resolves the same way in both namespaces. +# +# The traps clear them on any ordinary exit but not on a SIGKILL, and the +# builder's watch loop group-kills a run that overruns or is superseded, so +# sweep siblings old enough that no live run can own them. +find "$DEST_ABS" -maxdepth 1 -type d \( -name '.name.*' -o -name '.stage.*' \) \ + -mmin +120 -exec rm -rf {} + 2>/dev/null || : + +STAGE="" +# The body is last, not the test: written as `[ -n "$STAGE" ] && rm -rf ...`, +# an unset STAGE makes the test the handler's final command, the handler +# returns 1, and from an EXIT trap under `set -e` that becomes the script's +# exit status. +cleanup() { + if [ -n "$STAGE" ]; then + rm -rf "$STAGE" + fi +} +trap cleanup EXIT + +if [ "$NO_BUILD" -eq 0 ]; then + # Build the .deb in the pinned container and package the binaries out of + # it. That is one build rather than two, and it is the build that + # build-deb-container.sh has already run the glibc floor and Depends checks + # on, so the RPM cannot carry objects those checks never saw. + STAGE=$(mktemp -d "$DEST_ABS/.stage.XXXXXX") || { + echo "build-rpm-container: could not create a staging directory in $DEST_ABS" >&2 + exit 1 + } + DEB_DIR="$STAGE/deb" + BIN_DIR="$STAGE/bin" + mkdir -p "$DEB_DIR" "$BIN_DIR" + + deb_args=(--output-dir "$DEB_DIR") + [ -n "$VERSION" ] && deb_args+=(--version "$VERSION") + [ -n "$FEATURES" ] && deb_args+=(--features "$FEATURES") + + echo "=== Building the binaries in the pinned container ===" >&2 + DEB=$("$REPO_ROOT/packaging/debian/build-deb-container.sh" "${deb_args[@]}" | tail -n 1) + [ -f "$DEB" ] || { + echo "build-rpm-container: the Debian build did not produce a package" >&2 + exit 1 + } + + # dpkg-deb lives in the build image, not necessarily on a host that wants + # an RPM -- a Fedora workstation has no dpkg at all. + BUILD_IMAGE=$("$REPO_ROOT/packaging/debian/build-deb-container.sh" --print-image-tag) + docker run --rm \ + -v "$DEB_DIR":/deb:ro \ + -v "$BIN_DIR":/bin-out \ + -e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" \ + "$BUILD_IMAGE" \ + bash -euo pipefail -c ' + unpack=$(mktemp -d) + dpkg-deb -x /deb/*.deb "$unpack" + for binary in fips fipsctl fipstop fips-gateway; do + install -m 0755 "$unpack/usr/bin/$binary" "/bin-out/$binary" + done + chown "$HOST_UID:$HOST_GID" /bin-out/* + ' >&2 +fi + +: "${BIN_DIR:=$REPO_ROOT/target/release}" +BIN_DIR="$(cd "$BIN_DIR" && pwd)" + +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$REPO_ROOT" log -1 --format=%ct)}" + +# The version is derived on the host and passed in, because a worktree's .git +# is a file pointing outside the mount and git in the container cannot read it. +# Same reasoning as the Debian container build. +if [ -z "$VERSION" ]; then + CRATE_VERSION=$(awk -F'"' '/^version = /{print $2; exit}' "$REPO_ROOT/Cargo.toml") + if [[ "$CRATE_VERSION" == *-dev ]]; then + GIT_DATE=$(git -C "$REPO_ROOT" log -1 --format=%cs | tr -d '-') + GIT_SHA=$(git -C "$REPO_ROOT" rev-parse --short HEAD) + DIRTY="" + [ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ] && DIRTY=".dirty" + VERSION="${CRATE_VERSION%-dev}-0.dev.git${GIT_DATE}.${GIT_SHA}${DIRTY}" + else + VERSION="$CRATE_VERSION" + fi +fi + +# `docker run` pulls the image implicitly on a miss, and that pull is not +# retried by anything. It reaches a registry on every runner that has not seen +# the digest before, which is every fresh one. retry_build is what the Debian +# builder image uses, so a pull that fails and then succeeds leaves a warning +# on the run rather than passing silently. +if ! docker image inspect "$FIPS_RPM_BUILD_IMAGE" >/dev/null 2>&1; then + retry_build "docker pull $FIPS_RPM_BUILD_IMAGE" \ + docker pull --quiet "$FIPS_RPM_BUILD_IMAGE" >&2 +fi + +echo "=== Packaging fips $VERSION in $FIPS_RPM_BUILD_IMAGE ===" >&2 + +NAME_DIR=$(mktemp -d "$DEST_ABS/.name.XXXXXX") || { + echo "build-rpm-container: could not create a name directory in $DEST_ABS" >&2 + exit 1 +} +trap 'cleanup; rm -rf "$NAME_DIR"' EXIT + +# The features reached cargo in the build above, so the binaries already have +# them; what is left is to say so in the Release. build-rpm.sh refuses +# --features with --no-build for exactly that reason -- the flag would promise +# a build it is not doing -- so the marker is folded into the version here +# instead of passed inward. +if [ -n "$FEATURES" ]; then + MARKER="features.$(printf '%s' "$FEATURES" | tr -c 'a-zA-Z0-9.' '.')" + case "$VERSION" in + *-*) VERSION="${VERSION}.${MARKER}" ;; + *) VERSION="${VERSION}-1.${MARKER}" ;; + esac +fi + +rpm_args=(--no-build --bin-dir /bins --version "$VERSION" --output-dir /out --name-file /name/rpm) + +docker run --rm \ + -v "$REPO_ROOT":/src:ro \ + -v "$BIN_DIR":/bins:ro \ + -v "$DEST_ABS":/out \ + -v "$NAME_DIR":/name \ + -e SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \ + -e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" \ + -w /src \ + "$FIPS_RPM_BUILD_IMAGE" \ + bash -euo pipefail -c " + # Retried: this reaches a mirror, and a transient failure here would + # fail a release build that has nothing wrong with it. The Debian + # builder image gets the same treatment one layer up, in + # testing/lib/image-build.sh. + for attempt in 1 2 3; do + if dnf install -y --setopt=install_weak_deps=False rpm-build systemd-rpm-macros >/dev/null; then + break + fi + if [ \"\$attempt\" -eq 3 ]; then + echo 'could not install rpm-build and systemd-rpm-macros' >&2 + exit 1 + fi + sleep \$((attempt * 5)) + done + packaging/rpm/build-rpm.sh ${rpm_args[*]} + chown \"\$HOST_UID:\$HOST_GID\" /name/rpm + " >&2 + +RPM_NAME="" +[ -f "$NAME_DIR/rpm" ] && RPM_NAME=$(head -n 1 "$NAME_DIR/rpm") +[ -n "$RPM_NAME" ] || { + echo "build-rpm-container: the build did not name its package" >&2 + echo "build-rpm-container: the name travels through $NAME_DIR, bind-mounted as /name." >&2 + echo "build-rpm-container: if that path is not visible to the Docker daemon -- a private" >&2 + echo "build-rpm-container: /tmp is the usual cause -- the container wrote the name elsewhere." >&2 + exit 1 +} +if [[ "$RPM_NAME" == */* || "$RPM_NAME" != fips-mesh-*.rpm ]]; then + echo "build-rpm-container: the build named '$RPM_NAME', which is not a package file name" >&2 + exit 1 +fi + +RPM="$DEST_ABS/$RPM_NAME" +[ -f "$RPM" ] || { + echo "build-rpm-container: the build named $RPM_NAME but $RPM does not exist" >&2 + exit 1 +} + +# Check the artifact, not its inputs. rpm records the requirement it derived +# from the binaries, which is the table dnf enforces at install time, so this +# reads what a user's package manager will read. It runs in the rpm image +# because the host may have no rpm. +docker run --rm \ + -v "$REPO_ROOT":/src:ro \ + -v "$DEST_ABS":/out:ro \ + -w /src \ + "$FIPS_RPM_BUILD_IMAGE" \ + testing/check-rpm-floor.sh "/out/$RPM_NAME" >&2 + +echo "=== Built $RPM ===" >&2 +printf '%s\n' "$RPM" diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh new file mode 100755 index 00000000..78898bdb --- /dev/null +++ b/packaging/rpm/build-rpm.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# Build an .rpm package for FIPS. +# +# The counterpart of packaging/debian/build-deb.sh, and deliberately the same +# shape: the same options, the same dev-version derivation, the same output in +# deploy/. cargo-deb builds the binaries itself; here cargo builds them and +# rpmbuild packages what it produced, so one cargo invocation stays the only +# thing that compiles FIPS. +# +# Usage: ./build-rpm.sh [--target ] [--version ] [--no-build] +# [--features ] [--output-dir ] +# [--name-file ] [--bin-dir ] +# +# Prerequisites: rpm-build and systemd-rpm-macros. +# Output: deploy/fips-mesh--..rpm +# +# "fips-mesh", not "fips": Fedora's namespace has a `fips` package already (an +# unrelated FITS image viewer), and ours would look like an old version of it. +# See the header of fips.spec. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +SPEC="${SCRIPT_DIR}/fips.spec" + +usage() { + cat <<'EOF' +Usage: packaging/rpm/build-rpm.sh [options] + +Options: + --target Rust target triple to build/package + --version Override the RPM Version-Release. Accepts either + "" or "-". + --no-build Package existing binaries without running cargo build + --features Cargo features to build with (comma-separated). Marks the + auto-derived Release so the package is distinguishable + from a default build of the same commit. + --output-dir Where to put the finished .rpm. Defaults to deploy/ under + the project root. + +Environment: + HOST_UID, HOST_GID Give the finished package to this owner. Set by a + container build, whose root would otherwise leave a file + on the mounted tree that its owner cannot remove. + --name-file Also write the finished package's file name (basename + only) to . + --bin-dir Package the binaries in rather than the ones under + target/. Implies --no-build. This is how the container + build packages binaries compiled somewhere else. + -h, --help Show this help +EOF +} + +TARGET_TRIPLE="" +VERSION_OVERRIDE="" +BIN_DIR_OVERRIDE="" +NO_BUILD=0 +FEATURES="" +DEST_DIR="" +NAME_FILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + TARGET_TRIPLE="${2:?missing value for --target}" + shift 2 + ;; + --version) + VERSION_OVERRIDE="${2:?missing value for --version}" + shift 2 + ;; + --no-build) + NO_BUILD=1 + shift + ;; + --features) + FEATURES="${2:?missing value for --features}" + shift 2 + ;; + --output-dir) + DEST_DIR="${2:?missing value for --output-dir}" + shift 2 + ;; + --name-file) + NAME_FILE="${2:?missing value for --name-file}" + shift 2 + ;; + --bin-dir) + BIN_DIR_OVERRIDE="${2:?missing value for --bin-dir}" + NO_BUILD=1 + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +# Same refusal as the Debian build, for the same reason: a feature build that +# skips the build step would stamp a feature-marked Release onto whatever +# binaries already sit in target/, which is the one outcome the marking exists +# to prevent. +if [[ -n "${FEATURES}" && "${NO_BUILD}" -eq 1 ]]; then + echo "--features cannot be combined with --no-build: the features would not" >&2 + echo "reach the binaries, but the Release would claim they had." >&2 + exit 1 +fi + +cd "${PROJECT_ROOT}" + +if ! command -v rpmbuild &>/dev/null; then + echo "rpmbuild not found. Install the rpm-build package." >&2 + exit 1 +fi + +# Reproducible builds, as on the Debian side. +if [ -z "${SOURCE_DATE_EPOCH:-}" ]; then + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + export SOURCE_DATE_EPOCH +fi + +CRATE_VERSION=$(awk -F'"' '/^version = /{print $2; exit}' Cargo.toml) + +if [[ -n "${VERSION_OVERRIDE}" ]]; then + # Accept "" or "-". + RPM_VERSION="${VERSION_OVERRIDE%%-*}" + if [[ "${VERSION_OVERRIDE}" == *-* ]]; then + RPM_RELEASE="${VERSION_OVERRIDE#*-}" + else + RPM_RELEASE="1" + fi +elif [[ "${CRATE_VERSION}" == *-dev ]]; then + # A dev build gets a Release that sorts BELOW the eventual tagged release + # and differs between commits, so `dnf upgrade` on one dev package + # installed over another is not a silent no-op. rpm has understood "~" + # since 4.10 and the Debian version uses it; a Release beginning with 0. is + # the convention for pre-release packages here and is what this picks. + RPM_VERSION="${CRATE_VERSION%-dev}" + GIT_DATE=$(git log -1 --format=%cs | tr -d '-') + GIT_SHA=$(git rev-parse --short HEAD) + RPM_RELEASE="0.dev.git${GIT_DATE}.${GIT_SHA}" + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + RPM_RELEASE="${RPM_RELEASE}.dirty" + fi + # A feature build of a commit is a different package from the default + # build of the same commit, and nothing else in the version says so. The + # suffix sorts above the unsuffixed build, so installing a feature build + # is an upgrade and going back is a downgrade — which dnf refuses unless + # told; use `dnf downgrade` or `rpm -U --oldpackage`. + if [[ -n "${FEATURES}" ]]; then + RPM_RELEASE="${RPM_RELEASE}.features.$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')" + fi + echo "Auto-derived dev Version-Release: ${RPM_VERSION}-${RPM_RELEASE}" +else + RPM_VERSION="${CRATE_VERSION}" + RPM_RELEASE="1" +fi + +# Build the binaries, unless we were told they are already there. +if [[ "${NO_BUILD}" -eq 0 ]]; then + cargo_args=(build --release) + [[ -n "${TARGET_TRIPLE}" ]] && cargo_args+=(--target "${TARGET_TRIPLE}") + [[ -n "${FEATURES}" ]] && cargo_args+=(--features "${FEATURES}") + echo "Building binaries..." + cargo "${cargo_args[@]}" +fi + +if [[ -n "${BIN_DIR_OVERRIDE}" ]]; then + BIN_DIR="$(cd "${BIN_DIR_OVERRIDE}" && pwd)" + RPM_ARCH="$(rpm --eval '%{_target_cpu}')" +elif [[ -n "${TARGET_TRIPLE}" ]]; then + BIN_DIR="${PROJECT_ROOT}/target/${TARGET_TRIPLE}/release" + # rpm names architectures its own way; map the ones we cross-build for. + case "${TARGET_TRIPLE}" in + x86_64-*) RPM_ARCH="x86_64" ;; + aarch64-*) RPM_ARCH="aarch64" ;; + armv7-*) RPM_ARCH="armv7hl" ;; + riscv64-*) RPM_ARCH="riscv64" ;; + *) + echo "Unknown target triple for rpm: ${TARGET_TRIPLE}" >&2 + echo "Add it to the case in $(basename "$0")." >&2 + exit 1 + ;; + esac +else + BIN_DIR="${PROJECT_ROOT}/target/release" + RPM_ARCH="$(rpm --eval '%{_target_cpu}')" +fi + +for binary in fips fipsctl fipstop fips-gateway; do + if [[ ! -x "${BIN_DIR}/${binary}" ]]; then + echo "Missing ${BIN_DIR}/${binary}." >&2 + [[ "${NO_BUILD}" -eq 1 ]] && echo "Drop --no-build, or build first." >&2 + exit 1 + fi +done + +TOP_DIR="$(mktemp -d)" +trap 'rm -rf "${TOP_DIR}"' EXIT +mkdir -p "${TOP_DIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + +echo "Building .rpm package..." +# The Release carries no %{dist} tag. A dist tag says which distribution's +# build of a package this is, and there is one build: the same glibc binaries +# the .deb and the tarball ship, packaged. Leaving it in would name whichever +# image or host happened to run rpmbuild (.el9 from the release build, .fc44 +# from a developer's) in an artifact that installs on all of them. +rpmbuild -bb "${SPEC}" \ + --target "${RPM_ARCH}" \ + --define "dist %{nil}" \ + --define "_topdir ${TOP_DIR}" \ + --define "_sourcedir ${PROJECT_ROOT}" \ + --define "fips_srcdir ${PROJECT_ROOT}" \ + --define "fips_bindir ${BIN_DIR}" \ + --define "fips_version ${RPM_VERSION}" \ + --define "fips_release ${RPM_RELEASE}" \ + --quiet + +: "${DEST_DIR:=deploy}" +mkdir -p "${DEST_DIR}" +RPM_FILE=$(find "${TOP_DIR}/RPMS" -name '*.rpm' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2) + +if [ -z "${RPM_FILE}" ]; then + echo "Error: No .rpm file found under ${TOP_DIR}/RPMS" >&2 + exit 1 +fi + +cp "${RPM_FILE}" "${DEST_DIR}/" +BASENAME=$(basename "${RPM_FILE}") + +# A container build runs as root on a mounted source tree, which would leave a +# package its owner cannot delete without sudo. HOST_UID/HOST_GID say who asked +# for it; unset (the ordinary case, building as yourself) changes nothing. +if [[ -n "${HOST_UID:-}" && -n "${HOST_GID:-}" ]]; then + chown "${HOST_UID}:${HOST_GID}" "${DEST_DIR}/${BASENAME}" +fi +if [[ -n "${NAME_FILE}" ]]; then + printf '%s\n' "${BASENAME}" > "${NAME_FILE}" +fi + +# dnf needs a path it can tell from a package name, so a relative one gets a +# "./" and an absolute one is already unambiguous. +OUT_PATH="${DEST_DIR}/${BASENAME}" +case "${OUT_PATH}" in + /*) INSTALL_PATH="${OUT_PATH}" ;; + *) INSTALL_PATH="./${OUT_PATH}" ;; +esac + +echo "Package built: ${OUT_PATH}" +echo "" +echo "Install with: sudo dnf install ${INSTALL_PATH}" +echo "Remove with: sudo dnf remove fips-mesh (keeps /etc/fips and its identity keys)" + +# The last line of stdout is the package path, and nothing may follow it: the +# release workflow reads it to learn which package this run produced, exactly +# as it does with build-deb-container.sh. +echo "${OUT_PATH}" diff --git a/packaging/rpm/fips.spec b/packaging/rpm/fips.spec new file mode 100644 index 00000000..a61e8bd1 --- /dev/null +++ b/packaging/rpm/fips.spec @@ -0,0 +1,312 @@ +# FIPS RPM packaging. +# +# The counterpart of packaging/debian: the same files land in the same places, +# the same group is created, the same config is seeded, and the same two units +# are enabled. Where the two package managers differ, the differences are +# marked below rather than smoothed over. +# +# The binaries are built before rpmbuild runs, by packaging/rpm/build-rpm.sh, +# and this spec packages them. That is how cargo-deb works on the Debian side, +# and it keeps one cargo invocation — with its target directory, features and +# cross-compilation flags — as the only thing that compiles FIPS. So there is +# no %%prep and no %%build here, and `fips_bindir` says where the binaries are. +# +# Dependencies are not listed: rpmbuild derives them from the ELF files, down +# to the glibc and libdbus symbol versions, which is what the Debian side gets +# from "$auto" plus testing/check-deb-depends.sh. An RPM built on a host newer +# than the target therefore *refuses to install* there rather than installing +# and failing to start. rpm derives the glibc requirement from the binaries, so +# what needs checking is the binaries themselves: testing/check-rpm-floor.sh +# reads that requirement out of the finished package and compares it with +# FIPS_GLIBC_FLOOR — see packaging/README.md. + +%global fips_group fips +%global fips_libdir %{_prefix}/lib/fips + +# Where build-rpm.sh leaves the binaries and where the source tree is. Both are +# passed with --define; the defaults only exist so `rpmspec -q` can parse this +# file without them. +%{!?fips_bindir: %global fips_bindir %{_sourcedir}/target/release} +%{!?fips_srcdir: %global fips_srcdir %{_sourcedir}} + +# NOT "fips". Fedora's namespace already has a package by that name -- an +# OpenGL FITS image viewer (github.com/matwey/fips3), currently 3.4.0 -- and it +# owns /usr/bin/fips. A package named `fips` at 0.6.0 is therefore an *older* +# `fips` to every RPM tool there is, so a routine `dnf upgrade` replaces a +# running mesh node with an image viewer and takes the units with it. That is +# not hypothetical: it happened on a test machine within the hour, silently. +# +# The Debian side has no such collision, which is why only this name differs. +Name: fips-mesh +Version: %{?fips_version}%{!?fips_version:0.6.0} +Release: %{?fips_release}%{!?fips_release:1}%{?dist} +Summary: Free Internetworking Peering System mesh network daemon + +License: MIT +URL: https://github.com/jmcorgan/fips +# The source is the working tree, not a tarball: see the header. +Source0: %{name}-%{version}.tar.gz + +# Shipped by systemd, needed by the scriptlets below. +BuildRequires: systemd-rpm-macros + +Requires: systemd +# The FITS viewer owns /usr/bin/fips, so the two cannot both be installed. rpm +# would refuse on the file conflict anyway; saying so here makes the refusal +# name the problem instead of naming a path. +Conflicts: fips +# groupadd, used by %%post. openSUSE calls the package `shadow`; the rich +# dependency satisfies both without naming a distribution. +Requires(post): (shadow-utils or shadow) +# Bluetooth (BLE) transport at runtime; the daemon runs without it. +Recommends: bluez +# fips-firewall.service runs nft(8). Not required: the unit is opt-in and the +# daemon does not need it. +Recommends: nftables + +%description +FIPS is a distributed, decentralized network routing protocol for mesh nodes +connecting over arbitrary transports including UDP, TCP, Ethernet, Tor, and +Bluetooth (BLE). It provides encrypted peer-to-peer connectivity with automatic +key management, TUN-based virtual networking, and .fips DNS resolution. + +%prep +# Nothing to unpack: the binaries and the data files come from the working tree. + +%build +# Nothing to build: see the header. + +%install +install -D -m 0755 %{fips_bindir}/fips %{buildroot}%{_bindir}/fips +install -D -m 0755 %{fips_bindir}/fipsctl %{buildroot}%{_bindir}/fipsctl +install -D -m 0755 %{fips_bindir}/fipstop %{buildroot}%{_bindir}/fipstop +install -D -m 0755 %{fips_bindir}/fips-gateway %{buildroot}%{_bindir}/fips-gateway + +install -D -m 0755 %{fips_srcdir}/packaging/common/fips-dns-setup \ + %{buildroot}%{fips_libdir}/fips-dns-setup +install -D -m 0755 %{fips_srcdir}/packaging/common/fips-dns-teardown \ + %{buildroot}%{fips_libdir}/fips-dns-teardown + +# The units are the Debian package's, unmodified. Both packages install the +# binaries to %%{_bindir} and target the same systemd, so a second copy of four +# unit files would only be a second thing to keep in step. +install -D -m 0644 %{fips_srcdir}/packaging/debian/fips.service \ + %{buildroot}%{_unitdir}/fips.service +install -D -m 0644 %{fips_srcdir}/packaging/debian/fips-dns.service \ + %{buildroot}%{_unitdir}/fips-dns.service +install -D -m 0644 %{fips_srcdir}/packaging/debian/fips-firewall.service \ + %{buildroot}%{_unitdir}/fips-firewall.service +install -D -m 0644 %{fips_srcdir}/packaging/debian/fips-gateway.service \ + %{buildroot}%{_unitdir}/fips-gateway.service + +install -D -m 0644 %{fips_srcdir}/packaging/debian/fips.tmpfiles \ + %{buildroot}%{_tmpfilesdir}/fips.conf + +# The example config is read by %%post, so it is not under %%{_docdir}: minimal +# and container installs path-exclude that directory. Same reasoning as the +# Debian package. +install -D -m 0644 %{fips_srcdir}/packaging/common/fips.yaml \ + %{buildroot}%{_datadir}/fips/fips.yaml.example + +install -D -m 0644 %{fips_srcdir}/packaging/common/hosts \ + %{buildroot}%{_sysconfdir}/fips/hosts +install -D -m 0644 %{fips_srcdir}/packaging/common/fips.nft \ + %{buildroot}%{_sysconfdir}/fips/fips.nft + +# Drop-in directory for operator nftables rules included by /etc/fips/fips.nft. +# Empty by default; the include glob matches nothing cleanly out of the box. +install -d -m 0755 %{buildroot}%{_sysconfdir}/fips/fips.d + +install -D -m 0644 %{fips_srcdir}/docs/design/fips-security.md \ + %{buildroot}%{_docdir}/fips/fips-security.md +install -D -m 0644 %{fips_srcdir}/LICENSE %{buildroot}%{_licensedir}/fips/LICENSE + +%post +# Control-socket access is by group membership, so the group exists before the +# daemon can create the socket. +getent group %{fips_group} >/dev/null || groupadd --system %{fips_group} + +# Seed /etc/fips/fips.yaml from the shipped example only if it does not already +# exist. The live config is deliberately not a packaged config file: this +# copy-if-absent yields to any operator- or configuration-management-rendered +# file and never clobbers it, and never leaves a .rpmnew beside it either. +if [ ! -e %{_sysconfdir}/fips/fips.yaml ]; then + install -m 0600 -o root -g root \ + %{_datadir}/fips/fips.yaml.example \ + %{_sysconfdir}/fips/fips.yaml +fi + +if [ -d /run/systemd/system ]; then + systemd-tmpfiles --create %{_tmpfilesdir}/fips.conf >/dev/null 2>&1 || : +fi + +# Presets first: a distribution preset that disables these units is applied +# here, though the explicit enable below then overrides it, so the presets have +# the last word only on units this package does not name. +%systemd_post fips.service fips-dns.service + +# Then enable the two units the package considers its own: installing FIPS is +# how an operator asks for a mesh node, and a node that is installed but not +# enabled is not one. fips-firewall.service and fips-gateway.service are +# deliberately left alone — both are opt-in, see +# %%{_docdir}/fips/fips-security.md. +# +# On first install only, which is narrower than the Debian postinst: that one +# enables on every configure, so it re-enables a unit an operator has disabled. +# Here a later `systemctl disable fips` survives an upgrade, which is the +# behaviour an operator who disabled it would expect. +# +# Enabled, not started. The Debian postinst starts units only under +# `[ -n "$2" ]`, which holds on an upgrade and never on a fresh install, so a +# first install there leaves the node to the next boot or to the operator. +# Starting here would also mean fips-dns.service — Type=oneshot running +# fips-dns-setup — rewriting the host resolver inside the install transaction, +# against a fips.yaml seeded from the example seconds earlier. An upgrade +# queues a restart of whatever was running, in the try-restart in %%postun +# below. +# +# (A package submitted to Fedora proper would drop the enables too and let the +# distribution's presets decide, which is the policy there. This package is +# built upstream and installed deliberately, so it matches the .deb instead.) +if [ $1 -eq 1 ] && [ -d /run/systemd/system ]; then + systemctl enable fips.service >/dev/null 2>&1 || : + systemctl enable fips-dns.service >/dev/null 2>&1 || : +fi + +# On upgrade, reapply the firewall ruleset in place, before the daemon is +# restarted by %%systemd_postun_with_restart below -- %%post of the new package +# runs ahead of %%postun of the old one, which is the ordering the Debian +# postinst has. "try" leaves an inactive unit alone, so this never opts a host +# in, and it must be a reload rather than a restart: that unit's ExecStop +# deletes the table, and a restart would leave the mesh interface unfiltered +# in between. A reload that fails leaves the previous ruleset in force, so it +# is reported and the upgrade goes on. +if [ $1 -ge 2 ] && [ -d /run/systemd/system ]; then + # The unit files this upgrade installed are not loaded yet -- the reload + # systemd runs from a file trigger comes at the end of the transaction -- + # so without this the reload below would act on the pre-upgrade unit. + systemctl daemon-reload >/dev/null 2>&1 || : + if ! systemctl try-reload-or-restart fips-firewall.service >/dev/null 2>&1; then + echo "fips: reloading fips-firewall.service failed; the ruleset loaded before the upgrade stays in force" >&2 + echo "fips: check /etc/fips/fips.nft and the rules in /etc/fips/fips.d/" >&2 + fi +fi + +%preun +# Stops and disables only on the last erase, not on an upgrade. +%systemd_preun fips.service fips-dns.service fips-gateway.service fips-firewall.service + +%postun +# Restarts what was running, on upgrade only. fips-gateway.service is in the +# list because a host that opted it in would otherwise keep running the old +# binary; try-restart leaves an inactive unit alone, so listing it opts nobody +# in. fips-firewall.service is not: it is reloaded in %%post above, because +# restarting it would run its ExecStop and delete the table. +# +# Spelled out rather than left to %%systemd_postun_with_restart. That macro is +# expanded at build time, in the image this package is built in, and the EL9 +# expansion only *marks* the units -- `systemd-update-helper +# mark-restart-system-units` -- for a file trigger in that distribution's +# systemd package to act on. On a distribution without that trigger the mark is +# written and nothing ever reads it, so an upgrade silently leaves the old +# binary running. try-restart is portable, and is what the macro would have +# reached in the end anyway. +# +# Queued, not waited on. `systemctl try-restart` without --no-block returns +# when the jobs finish, and this scriptlet runs inside the rpm transaction, +# holding dnf's lock: fips-dns.service is Type=oneshot and fips-dns-setup waits +# up to 30 s for fips0, so a daemon that comes back slowly -- or not at all -- +# would hold the whole upgrade there. The restart is a request; whether it +# succeeds is the daemon's business and the journal's, not the package +# manager's. +# +# This is also where the RPM deliberately parts from the Debian postinst, +# which waits for each unit it starts with a bounded poll. That bound exists +# because a blocking `systemctl start` under dpkg held apt, and every package +# operation queued behind it, for ever. Queuing the restart avoids the problem +# the bound was written to contain, rather than reimplementing the bound. +if [ $1 -ge 1 ] && [ -d /run/systemd/system ]; then + systemctl --no-block try-restart fips.service fips-dns.service fips-gateway.service >/dev/null 2>&1 || : +fi + +if [ $1 -eq 0 ]; then + # The runtime directory is not packaged, so nothing else removes it. + rm -rf /run/fips + + # DNS configuration fips-dns-setup may have written outside the package, + # one file per backend it picks between. fips-dns-teardown runs on + # ExecStop and removes the file of the backend recorded in its state file, + # or all four when the state file is missing; %%systemd_preun stops the unit + # before rpm gets here, so this is what catches a host where the unit was + # not running. Each resolver whose file is removed is told to drop it, as + # the Debian postrm does: otherwise a host erased while fips-dns was stopped + # keeps sending .fips queries to the daemon's resolver port until that + # resolver next restarts. rpm has no purge, so this erase branch is the only + # cleanup that will ever run. + restart_resolved=0 + if [ -f %{_sysconfdir}/systemd/dns-delegate.d/fips.dns-delegate ]; then + rm -f %{_sysconfdir}/systemd/dns-delegate.d/fips.dns-delegate + restart_resolved=1 + fi + if [ -f %{_sysconfdir}/systemd/resolved.conf.d/fips.conf ]; then + rm -f %{_sysconfdir}/systemd/resolved.conf.d/fips.conf + restart_resolved=1 + fi + if [ "$restart_resolved" = 1 ] && [ -d /run/systemd/system ] \ + && systemctl is-active --quiet systemd-resolved.service; then + systemctl restart systemd-resolved \ + || echo "fips: warning: could not restart systemd-resolved; restart it to drop the .fips route" + fi + if [ -f %{_sysconfdir}/dnsmasq.d/fips.conf ]; then + rm -f %{_sysconfdir}/dnsmasq.d/fips.conf + if [ -d /run/systemd/system ] \ + && systemctl is-active --quiet dnsmasq.service; then + systemctl reload dnsmasq \ + || echo "fips: warning: could not reload dnsmasq; reload it to drop the .fips route" + fi + fi + if [ -f %{_sysconfdir}/NetworkManager/dnsmasq.d/fips.conf ]; then + rm -f %{_sysconfdir}/NetworkManager/dnsmasq.d/fips.conf + if [ -d /run/systemd/system ] \ + && systemctl is-active --quiet NetworkManager.service \ + && command -v nmcli >/dev/null 2>&1; then + nmcli general reload \ + || echo "fips: warning: could not reload NetworkManager; reload it to drop the .fips route" + fi + fi +fi + +# Note what is *not* here. The Debian postrm removes /etc/fips and the fips +# group on purge — an explicit, separate operator action. rpm has no purge, so +# the equivalent code would run on an ordinary erase and take the node's +# identity keys with it, including during a distribution upgrade that erases +# and reinstalls. Config and keys therefore survive an erase; remove +# /etc/fips yourself if you mean it. + +%files +%dir %{_licensedir}/fips +%license %{_licensedir}/fips/LICENSE +%dir %{_docdir}/fips +%doc %{_docdir}/fips/fips-security.md +%{_bindir}/fips +%{_bindir}/fipsctl +%{_bindir}/fipstop +%{_bindir}/fips-gateway +%dir %{fips_libdir} +%{fips_libdir}/fips-dns-setup +%{fips_libdir}/fips-dns-teardown +%{_unitdir}/fips.service +%{_unitdir}/fips-dns.service +%{_unitdir}/fips-firewall.service +%{_unitdir}/fips-gateway.service +%{_tmpfilesdir}/fips.conf +%dir %{_datadir}/fips +%{_datadir}/fips/fips.yaml.example +%dir %{_sysconfdir}/fips +%dir %{_sysconfdir}/fips/fips.d +%config(noreplace) %{_sysconfdir}/fips/hosts +%config(noreplace) %{_sysconfdir}/fips/fips.nft + +%changelog +* Sat Sep 19 2026 Johnathan Corgan +- Packaging for RPM-based distributions, translated from the Debian recipe. diff --git a/testing/check-rpm-floor.sh b/testing/check-rpm-floor.sh new file mode 100755 index 00000000..3397a93b --- /dev/null +++ b/testing/check-rpm-floor.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Fail when an RPM records a glibc requirement above the declared floor. +# +# The counterpart of check-deb-depends.sh, for the other package format and for +# a different failure. On the Debian side the package's Depends are written by +# hand and can disagree with what the binaries need, so that check compares the +# two. rpm derives the requirement from the ELF files and cannot disagree with +# them -- which moves the risk one step back: the binaries themselves may have +# been built somewhere above the floor, and the package that results installs +# nowhere older, silently, until someone tries. +# +# This reads the requirement out of the finished package, which is the artifact +# that ships and the same table dnf enforces at install time. +# +# Usage: check-rpm-floor.sh ... +# +# Reads the floor from packaging/build-floor.env unless FIPS_GLIBC_FLOOR is set. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [ -z "${FIPS_GLIBC_FLOOR:-}" ]; then + # shellcheck source=../packaging/build-floor.env + . "$REPO_ROOT/packaging/build-floor.env" +fi +FLOOR="${FIPS_GLIBC_FLOOR:?no floor declared}" + +command -v rpm >/dev/null 2>&1 || { + echo "check-rpm-floor: rpm is not installed; cannot check anything." >&2 + echo " Refusing to report a pass I did not establish." >&2 + exit 2 +} + +[ $# -gt 0 ] || { + echo "usage: check-rpm-floor.sh ..." >&2 + exit 2 +} + +# Sorts versions the way rpm does, so 2.10 is above 2.9 rather than below it. +version_gt() { + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] && [ "$1" != "$2" ] +} + +FAILED=0 +CHECKED=0 + +for pkg in "$@"; do + if [ ! -f "$pkg" ]; then + echo " ERROR $pkg does not exist" >&2 + FAILED=$((FAILED + 1)) + continue + fi + + # Every libc.so.6(GLIBC_x.y) entry rpm derived from the packaged binaries. + # The highest one is the floor the package will be held to. + need=$(rpm -qp --requires "$pkg" 2>/dev/null \ + | grep -oE 'GLIBC_[0-9.]+' \ + | sed 's/GLIBC_//' \ + | sort -V \ + | tail -1) || true + + if [ -z "$need" ]; then + # No requirement at all means the package holds no dynamically linked + # binary, which for this package means the file list moved. Not a pass. + echo " ERROR $(basename "$pkg") records no glibc requirement" >&2 + FAILED=$((FAILED + 1)) + continue + fi + + CHECKED=$((CHECKED + 1)) + if version_gt "$need" "$FLOOR"; then + echo " FAIL $(basename "$pkg") requires glibc $need, above the declared floor $FLOOR" >&2 + FAILED=$((FAILED + 1)) + else + echo " ok $(basename "$pkg") requires glibc $need" + fi +done + +if [ "$FAILED" -ne 0 ]; then + echo "check-rpm-floor: $FAILED check(s) failed against floor $FLOOR." >&2 + echo " The package was built from binaries compiled above the floor. Build" >&2 + echo " them in the pinned container: packaging/rpm/build-rpm-container.sh." >&2 + exit 1 +fi + +if [ "$CHECKED" -eq 0 ]; then + echo "check-rpm-floor: nothing was checked; refusing to report a pass." >&2 + exit 2 +fi + +echo "=== RPM glibc floor check passed ($CHECKED package(s)) ==="