Package FIPS for RPM-based distributions

Add an RPM package for Fedora and RHEL. `make -C packaging rpm` builds it
from packaging/rpm/fips.spec, and the release workflow attaches it beside
the .deb and the systemd tarball.

- The package is named `fips-mesh`, because Fedora already ships an
  unrelated `fips` (a FITS image viewer) that owns /usr/bin/fips. The spec
  declares `Conflicts: fips`.
- It installs the same files, units and fips group as the .deb. fips.service
  and fips-dns.service are enabled but not started on install;
  fips-firewall and fips-gateway stay opt-in.
- An upgrade queues `systemctl --no-block try-restart` of fips, fips-dns and
  fips-gateway, and reloads fips-firewall rather than restarting it.
- The binaries come from the pinned build image via build-deb-container.sh,
  so the RPM passes the same glibc floor and dependency checks as the .deb.
  rpmbuild runs in FIPS_RPM_BUILD_IMAGE, AlmaLinux 9 pinned by digest.
- The glibc floor is now 2.34 project-wide, the lowest supported RPM
  distribution (RHEL 9). testing/check-rpm-floor.sh fails a package that
  requires a newer glibc. RHEL 8 and openSUSE are not supported.
- Erase removes the DNS drop-ins fips-dns-setup wrote and restarts or
  reloads the resolver whose file it removed, as the Debian postrm does.
  /etc/fips is kept, since rpm has no purge.
- Dev builds are versioned 0.6.0-0.dev.git<date>.<sha>.

Tested on Fedora 44 and in AlmaLinux 9 containers with systemd: the package
requires GLIBC_2.34 and passes the floor check; install leaves both units
enabled and inactive; upgrade returns immediately and the daemon restarts
on the new binary; erase removes the units and DNS files and keeps
/etc/fips; host-built binaries (GLIBC_2.39) fail the floor check; dnf
refuses to install alongside the FITS viewer. The erase branch's resolver
restart and reload were exercised in AlmaLinux 9 with systemctl stubbed.

No install-test suite covers the RPM yet; it is only built.
This commit is contained in:
Gustavo Lima Chaves
2026-09-26 15:58:19 +00:00
committed by Johnathan Corgan
parent 0d77dbe2de
commit 17ca52e694
11 changed files with 1251 additions and 22 deletions
+45 -2
View File
@@ -199,6 +199,44 @@ jobs:
done done
rm -rf "$UNPACK" 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 - name: Build systemd tarball
env: env:
STRIP: llvm-strip STRIP: llvm-strip
@@ -240,13 +278,15 @@ jobs:
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "deb=${{ steps.deb.outputs.deb }}" >> "$GITHUB_OUTPUT" echo "deb=${{ steps.deb.outputs.deb }}" >> "$GITHUB_OUTPUT"
echo "rpm=${{ steps.rpm.outputs.rpm }}" >> "$GITHUB_OUTPUT"
- name: SHA-256 hashes - name: SHA-256 hashes
run: | run: |
echo "==> Linux release assets:" echo "==> Linux release assets:"
sha256sum \ sha256sum \
"${{ steps.linux-assets.outputs.tarball }}" \ "${{ 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) - name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }} if: ${{ env.ACT != 'true' }}
@@ -256,6 +296,7 @@ jobs:
path: | path: |
${{ steps.linux-assets.outputs.tarball }} ${{ steps.linux-assets.outputs.tarball }}
${{ steps.linux-assets.outputs.deb }} ${{ steps.linux-assets.outputs.deb }}
${{ steps.linux-assets.outputs.rpm }}
retention-days: 30 retention-days: 30
- name: Build Summary - name: Build Summary
@@ -263,6 +304,7 @@ jobs:
echo "Build Summary for linux/${{ matrix.artifact_arch }}:" echo "Build Summary for linux/${{ matrix.artifact_arch }}:"
echo " Tarball: ${{ steps.linux-assets.outputs.tarball }}" echo " Tarball: ${{ steps.linux-assets.outputs.tarball }}"
echo " Debian: ${{ steps.linux-assets.outputs.deb }}" echo " Debian: ${{ steps.linux-assets.outputs.deb }}"
echo " RPM: ${{ steps.linux-assets.outputs.rpm }}"
release: release:
name: Publish Linux assets to GitHub Release name: Publish Linux assets to GitHub Release
@@ -282,7 +324,7 @@ jobs:
- name: Generate Linux release checksums - name: Generate Linux release checksums
run: | run: |
cd dist 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 \ | LC_ALL=C sort \
| xargs sha256sum \ | xargs sha256sum \
> checksums-linux.txt > checksums-linux.txt
@@ -308,6 +350,7 @@ jobs:
run: | run: |
gh release upload "${GITHUB_REF_NAME}" \ gh release upload "${GITHUB_REF_NAME}" \
dist/*.deb \ dist/*.deb \
dist/*.rpm \
dist/*.tar.gz \ dist/*.tar.gz \
dist/checksums-linux.txt \ dist/checksums-linux.txt \
--clobber \ --clobber \
+31
View File
@@ -176,6 +176,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### Packaging #### 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 - A pfSense package (`packaging/pfsense/`, `gmake pfsense`). pfSense is
FreeBSD underneath, but the FreeBSD package fails there in three FreeBSD underneath, but the FreeBSD package fails there in three
silent ways: pfSense runs only `/usr/local/etc/rc.d/*.sh` at boot and 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 binder tearing down and rebinding every second while teardown silently
declined to abort anything. 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) #### Packaging (Debian)
- An upgrade of the `.deb` now reapplies the firewall ruleset in place. Until - An upgrade of the `.deb` now reapplies the firewall ruleset in place. Until
+22 -1
View File
@@ -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 enables `fips` and `fips-dns` but starts neither, which is why the
second command is there. second command is there.
On Fedora or RHEL, download `fips-mesh-<version>-<release>.x86_64.rpm` (or
`.aarch64.rpm`) and install it:
```bash
sudo dnf install ./fips-mesh-<version>-<release>.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 For macOS, Windows, FreeBSD (including a pfSense build under
`packaging/pfsense/`), OpenWrt, the systemd tarball or a Nix `packaging/pfsense/`), OpenWrt, the systemd tarball or a Nix
flake, see [docs/getting-started.md](docs/getting-started.md) 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 are the same glibc build, and what
differs is the packaging: Debian and Ubuntu take the same `.deb`, Arch differs is the packaging: Debian and Ubuntu take the same `.deb`, Arch
takes `fips` from the AUR, and NixOS uses the Nix flake described 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 `deb-install` suite across debian12, debian13, ubuntu22, ubuntu24 and
ubuntu26; neither the AUR package nor the flake is. That suite runs on 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 every push and pull request, on x86_64, against a `.deb` built by the same
+12 -7
View File
@@ -59,6 +59,8 @@ The most direct path. The release distribution carries a
per-platform installer: per-platform installer:
- Debian/Ubuntu: `.deb` package - 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 - Arch Linux: `fips` AUR package
- OpenWrt: `.ipk` and `.apk` packages - OpenWrt: `.ipk` and `.apk` packages
- macOS: `.pkg` installer - macOS: `.pkg` installer
@@ -66,13 +68,16 @@ per-platform installer:
- Windows: `.zip` with service-install scripts - Windows: `.zip` with service-install scripts
- Generic systemd Linux: `.tar.gz` with an `install.sh` script - Generic systemd Linux: `.tar.gz` with an `install.sh` script
The `.deb` and the systemd tarball support every version of a glibc The `.deb`, the `.rpm` and the systemd tarball carry the same binaries and
distribution that its vendor still supports for free: currently Ubuntu support every version of a glibc distribution that its vendor still supports
22.04, Debian 12, Ubuntu 24.04, Debian 13 and Ubuntu 26.04. Those binaries for free: currently Ubuntu 22.04, Debian 12, Ubuntu 24.04, Debian 13 and
are built in a container pinned to the oldest of them, so they run on all Ubuntu 26.04 on the Debian side, and RHEL 9 and later on the RPM side. Those
five, and the glibc floor that follows is declared in binaries are built in a container pinned to the oldest Debian-family member,
`packaging/build-floor.env` and checked by `testing/check-glibc-floor.sh` on and the floor they are held to is the lowest of either family — RHEL 9's glibc
what the release workflow produces. Arch and NixOS build from source on your 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 own machine, and OpenWrt is a musl target rather than glibc, so none of them
depends on that floor. depends on that floor.
+22 -1
View File
@@ -6,6 +6,8 @@
# Usage: # Usage:
# make deb Build a Debian/Ubuntu .deb package in the pinned container # 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 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 tarball Build a systemd install tarball
# make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier) # 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+) # 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)))) PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) 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 all: deb tarball
@@ -40,6 +42,25 @@ deb:
deb-host: deb-host:
@bash $(PACKAGING_DIR)/debian/build-deb.sh @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: tarball:
@bash $(PACKAGING_DIR)/systemd/build-tarball.sh @bash $(PACKAGING_DIR)/systemd/build-tarball.sh
+125 -2
View File
@@ -8,6 +8,7 @@ and `make apk` write to `dist/` instead.
```sh ```sh
make deb # Debian/Ubuntu .deb (built in the pinned container) 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 tarball # systemd install tarball
make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier) make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier)
make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+) make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+)
@@ -22,9 +23,11 @@ make all # deb + tarball (default)
## The two Debian build paths ## The two Debian build paths
`make deb` builds in a container pinned to the oldest supported `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 [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 docker: the toolchain and the build dependencies live in the image. This
is the path the release workflow, the integration suite and the internal 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 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, common/ Shared assets (default config, hosts file) and pkg-lib.sh,
the helpers the FreeBSD and pfSense builders share the helpers the FreeBSD and pfSense builders share
debian/ Debian/Ubuntu .deb packaging via cargo-deb 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) freebsd/ FreeBSD .pkg packaging via pkg-create(8)
pfsense/ pfSense .pkg packaging (FreeBSD-based, but not the same) pfsense/ pfSense .pkg packaging (FreeBSD-based, but not the same)
macos/ macOS .pkg installer via pkgbuild macos/ macOS .pkg installer via pkgbuild
@@ -118,6 +123,124 @@ sudo dpkg -r fips
sudo dpkg -P 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-<version>-<release>.<arch>.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<date>.<sha>` rather
than the `.deb`'s `0.6.0~dev+git<date>.<sha>-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 ### systemd Tarball
A self-contained tarball with binaries and an `install.sh` script for A self-contained tarball with binaries and an `install.sh` script for
+60 -9
View File
@@ -1,8 +1,10 @@
# The glibc floor for the Linux release artifacts, and the image that produces it. # The glibc floor for the Linux release artifacts, and the image that produces it.
# #
# Sourced by packaging/debian/build-deb-container.sh and by # Sourced by packaging/debian/build-deb-container.sh,
# testing/check-glibc-floor.sh. It exists so the floor is a decision written # packaging/rpm/build-rpm-container.sh, testing/check-glibc-floor.sh and
# down in one place rather than a side effect of whichever build host ran last. # 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 # 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 # 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 # Debian 13 glibc 2.41 LTS ends 2030-06-30
# Ubuntu 26.04 glibc 2.43 # Ubuntu 26.04 glibc 2.43
# #
# so the lowest is Ubuntu 22.04 and the floor is its 2.35. Debian 11 left the # and on the RPM side, where the same binaries ship as fips-mesh:
# set on 2026-08-31 and is deliberately not counted. #
# 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 # Deliberately NOT a GitHub runner label. Runner availability follows GitHub's
# rule of supporting the newest two images; the floor follows distributors' # 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 # 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. # 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" 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 # 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 # FIPS_BUILD_IMAGE currently yields exactly this, so there is no headroom left:
# little headroom: the check is an upper bound, not an equality. # the check is an upper bound, and the build sits on it. A change that raises
FIPS_GLIBC_FLOOR="2.35" # what the binaries need will fail check-glibc-floor.sh rather than ship a
# package RHEL 9 refuses.
FIPS_GLIBC_FLOOR="2.34"
+265
View File
@@ -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"
+264
View File
@@ -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 <triple>] [--version <version>] [--no-build]
# [--features <list>] [--output-dir <dir>]
# [--name-file <path>] [--bin-dir <dir>]
#
# Prerequisites: rpm-build and systemd-rpm-macros.
# Output: deploy/fips-mesh-<version>-<release>.<arch>.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 <triple> Rust target triple to build/package
--version <version> Override the RPM Version-Release. Accepts either
"<version>" or "<version>-<release>".
--no-build Package existing binaries without running cargo build
--features <list> 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 <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 <path> Also write the finished package's file name (basename
only) to <path>.
--bin-dir <dir> Package the binaries in <dir> 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 "<version>" or "<version>-<release>".
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}"
+312
View File
@@ -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 <johnathan@corganlabs.com>
- Packaging for RPM-based distributions, translated from the Debian recipe.
+93
View File
@@ -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 <package.rpm>...
#
# 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 <package.rpm>..." >&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)) ==="