Merge the maintenance line up

This commit is contained in:
Johnathan Corgan
2026-09-06 00:32:58 +00:00
16 changed files with 885 additions and 154 deletions
+15
View File
@@ -0,0 +1,15 @@
# Keep the build context small.
#
# Several test harnesses build images with the repository root as the context
# (testing/deb-install/test.sh and testing/dns-resolver/test.sh among them), and
# without this every one of them uploads the whole Cargo target directory to the
# daemon before running a build that does not use a single file from it. On a
# developer's machine that directory reaches double-digit gigabytes.
#
# Deliberately narrow. Nothing here excludes testing/**/.cache, which
# testing/deb-install/test.sh copies a package out of, and no Dockerfile in the
# tree copies from target/ on the host: examples/k8s-sidecar/Dockerfile builds
# its own inside the image with a multi-stage COPY --from.
target/
.git/
deploy/
+112 -54
View File
@@ -27,7 +27,8 @@ env:
# ─────────────────────────────────────────────────────────────────────────────
# CI parity invariant
#
# This GitHub integration matrix and the local default suite set
# This workflow's integration matrices — the `integration:` job and the
# `deb-install:` job — and the local default suite set
# (testing/ci-local.sh) MUST run the same integration suites, EXCEPT for the
# deliberate local-only entries below. Adding a suite to one runner without
# the other means "local green" and "GitHub green" stop being equivalent.
@@ -504,29 +505,6 @@ jobs:
# recovers from delay, and never panics.
- suite: stun-faults
type: stun-faults
# ── Real-deb install across target distros ─────────────────────
# Boots a privileged systemd container per distro, runs
# `apt install ./fips_*.deb` with the locally-built package,
# then asserts end-to-end `.fips` resolution + the
# gateway/daemon default-pairing. The most thorough single
# test surface — exercises packaging, maintainer scripts,
# systemd unit ordering, real TUN, and the DNS responder
# filter on a per-distro resolver backend.
- suite: deb-install-debian12
type: deb-install
scenario: debian12
- suite: deb-install-debian13
type: deb-install
scenario: debian13
- suite: deb-install-ubuntu22
type: deb-install
scenario: ubuntu22
- suite: deb-install-ubuntu24
type: deb-install
scenario: ubuntu24
- suite: deb-install-ubuntu26
type: deb-install
scenario: ubuntu26
# ── DNS resolver multi-backend coverage ────────────────────────
# Exercises every fips-dns-setup backend (resolved, dnsmasq,
# NM+dnsmasq, dns-delegate, no-resolver) across five distros,
@@ -734,36 +712,6 @@ jobs:
docker compose -f testing/static/docker-compose.yml \
--profile gateway down --volumes --remove-orphans
# ── Real-deb install integration ────────────────────────────────────
# The deb-install harness builds its own .deb from source in a
# cargo-deb builder image; the pre-built Linux binary from the
# build job is intentionally not used here so the test exercises
# the full packaging pipeline. ~5-7 min cold-cache on a fresh
# runner (.deb build dominates), ~1-2 min warm-cache.
- name: Run deb-install scenario
if: matrix.type == 'deb-install'
timeout-minutes: 25
run: bash testing/deb-install/test.sh ${{ matrix.scenario }}
- name: Collect logs on failure (deb-install)
if: matrix.type == 'deb-install' && failure()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
echo "--- ${c} fips.service ---"
docker exec "$c" journalctl -u fips.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-dns.service ---"
docker exec "$c" journalctl -u fips-dns.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-gateway.service ---"
docker exec "$c" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -100 || true
done
- name: Stop containers (deb-install)
if: matrix.type == 'deb-install' && always()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
docker rm -f "$c" >/dev/null 2>&1 || true
done
# ── Native datagram API ─────────────────────────────────────────────
# Reads FIPS_TEST_IMAGE rather than defaulting to a name, so it runs
# against the image this workflow built. The two-node check creates and
@@ -817,3 +765,113 @@ jobs:
docker ps -a --filter "name=fips-dns-test-" --format '{{.Names}}' | while read -r c; do
docker rm -f "$c" >/dev/null 2>&1 || true
done
# ─────────────────────────────────────────────────────────────────────────────
# Job 4 – The .deb the install suite installs
#
# Built once, here, by the same script the release workflow and a local run
# call, so the package the suite installs is built the way the shipped one is.
# That was not true before: each install leg built its own package on a fresh
# runner with no cache, so one run performed five complete Rust release builds
# and four were waste — and none of them was built the way the release is, so
# the suite could not exhibit a defect that only the release environment
# produced.
#
# The script builds in the pinned container from packaging/build-floor.env and
# runs testing/check-glibc-floor.sh on the result, so this job is also where a
# floor violation stops the run.
# ─────────────────────────────────────────────────────────────────────────────
deb-package:
name: Build .deb
runs-on: ubuntu-latest
needs: [build, test]
if: ${{ !inputs.skip_integration }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Build the .deb in the pinned build container
timeout-minutes: 30
run: bash packaging/debian/build-deb-container.sh --output-dir deploy
- name: Upload the .deb
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: fips-deb
path: deploy/fips_*.deb
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────────────────────
# Job 5 – Real-deb install across target distros
#
# Boots a privileged systemd container per distro, runs `apt install
# ./fips_*.deb` with the package job 4 built, then asserts end-to-end `.fips`
# resolution + the gateway/daemon default-pairing. The most thorough single
# test surface — exercises packaging, maintainer scripts, systemd unit
# ordering, real TUN, and the DNS responder filter on a per-distro resolver
# backend.
#
# A job of its own rather than legs of the integration matrix: the install legs
# are the only ones that need the package, and as integration legs every other
# integration suite would wait on the package build.
#
# The legs keep `type: deb-install` and `scenario:` because
# testing/check-ci-parity.sh reads those to match this matrix against the local
# suite's distro list; the steps below use `scenario:` only.
# ─────────────────────────────────────────────────────────────────────────────
deb-install:
name: Deb install (${{ matrix.scenario }})
runs-on: ubuntu-latest
needs: [deb-package]
if: ${{ !inputs.skip_integration }}
strategy:
fail-fast: false
matrix:
include:
- type: deb-install
scenario: debian12
- type: deb-install
scenario: debian13
- type: deb-install
scenario: ubuntu22
- type: deb-install
scenario: ubuntu24
- type: deb-install
scenario: ubuntu26
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Download the .deb
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: fips-deb
path: _deb
- name: Run deb-install scenario
timeout-minutes: 25
run: |
deb=$(find _deb -maxdepth 1 -type f -name 'fips_*.deb' | sort | head -1)
[ -n "$deb" ] || { echo "no .deb in the downloaded artifact" >&2; exit 1; }
bash testing/deb-install/test.sh --deb "$deb" ${{ matrix.scenario }}
- name: Collect logs on failure
if: failure()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
echo "--- ${c} fips.service ---"
docker exec "$c" journalctl -u fips.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-dns.service ---"
docker exec "$c" journalctl -u fips-dns.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-gateway.service ---"
docker exec "$c" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -100 || true
done
- name: Stop containers
if: always()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
docker rm -f "$c" >/dev/null 2>&1 || true
done
+88 -33
View File
@@ -49,6 +49,11 @@ jobs:
runs-on: ${{ matrix.os }}
needs: determine-versioning
# Both legs build in the same pinned container. Nothing passes --platform,
# so the arm runner resolves the arm64 variant of the base image and builds
# natively; the floor check runs on that package too, so an aarch64 build
# above the floor fails the leg rather than shipping. What the runner
# supplies is Docker and the checkout -- neither leg compiles on the host.
strategy:
fail-fast: false
matrix:
@@ -68,32 +73,76 @@ jobs:
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm
# The host no longer compiles anything: the container carries the
# toolchain and the build dependencies. llvm is here only for llvm-strip,
# which build-tarball.sh uses on the binaries recovered from the package.
- name: Install host packaging tools
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache: false
rustflags: ''
# Build in the pinned container rather than on the runner. The runner's
# glibc is what put a GLIBC_2.39 requirement into every Linux artifact
# from v0.3.0 onward, so the package installed cleanly and then could not
# load on Debian 12 or Ubuntu 22.04. packaging/build-floor.env declares
# the base image and the floor; the script builds there and runs
# testing/check-glibc-floor.sh on the package it produced, so a build that
# would ship an unloadable binary fails here instead of at the user.
#
# This is the same script ci.yml and a local run call, so the package that
# passes the five-distro suite is built the way this one is.
- name: Build Debian package in the pinned container
id: deb
shell: bash
run: |
set -euo pipefail
: ${GITHUB_OUTPUT:=/tmp/github_output}
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-
packaging/debian/build-deb-container.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--output-dir deploy \
| tee /tmp/build-deb-container.log
- name: Install cargo-deb
run: cargo install cargo-deb --version 3.6.3 --locked
# The script prints the package path as its last line of stdout.
# Only stdout is captured; its diagnostics go to stderr and straight
# to the job log, so nothing can land after the path.
DEB_FILE=$(tail -n 1 /tmp/build-deb-container.log)
if [[ ! -f "$DEB_FILE" ]]; then
echo "build-deb-container.sh did not name a package: '$DEB_FILE'" >&2
exit 1
fi
case "$DEB_FILE" in
*_${{ matrix.deb_arch }}.deb) ;;
*)
echo "Package $DEB_FILE is not ${{ matrix.deb_arch }}" >&2
exit 1
;;
esac
- name: Build release binaries
run: cargo build --release
# Record it relative to the checkout: upload-artifact derives the
# archive layout from the common ancestor of its paths, and an
# absolute path here would nest the package under directories the
# release job's dist/*.deb glob does not look in.
echo "deb=${DEB_FILE#"$PWD"/}" >> "$GITHUB_OUTPUT"
# The container writes its target directory to a Docker volume, so the
# runner's target/release is empty. Recover the four binaries from the
# package instead: they are the container-built ones, so the tarball ships
# what the package ships rather than a second, runner-built set that the
# floor check never saw and that no package manager would refuse.
- name: Stage container-built binaries for the tarball
shell: bash
run: |
set -euo pipefail
UNPACK=$(mktemp -d)
dpkg-deb -x "${{ steps.deb.outputs.deb }}" "$UNPACK"
mkdir -p target/release
for bin in fips fipsctl fipstop fips-gateway; do
if [[ ! -f "$UNPACK/usr/bin/$bin" ]]; then
echo "Package is missing usr/bin/$bin" >&2
exit 1
fi
install -m 0755 "$UNPACK/usr/bin/$bin" "target/release/$bin"
done
rm -rf "$UNPACK"
- name: Build systemd tarball
env:
@@ -104,11 +153,23 @@ jobs:
--arch "${{ matrix.artifact_arch }}" \
--no-build
- name: Build Debian package
# The tarball has no package manager to refuse it, so nothing at install
# time would notice a bad floor. Check the binaries out of the finished
# tarball, after the strip, rather than trusting that they are the same
# objects the package check already passed.
- name: Check the tarball against the declared glibc floor
shell: bash
run: |
packaging/debian/build-deb.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--no-build
set -euo pipefail
TARBALL="deploy/fips-${{ needs.determine-versioning.outputs.linux_package_version }}-linux-${{ matrix.artifact_arch }}.tar.gz"
UNPACK=$(mktemp -d)
tar -xzf "$TARBALL" -C "$UNPACK"
testing/check-glibc-floor.sh \
"$UNPACK"/*/fips \
"$UNPACK"/*/fipsctl \
"$UNPACK"/*/fipstop \
"$UNPACK"/*/fips-gateway
rm -rf "$UNPACK"
- name: Resolve Linux asset paths
id: linux-assets
@@ -122,14 +183,8 @@ jobs:
exit 1
fi
DEB_FILE=$(find deploy -maxdepth 1 -type f -name "fips_*_${{ matrix.deb_arch }}.deb" | sort | head -n 1)
if [[ -z "$DEB_FILE" ]]; then
echo "Missing Debian package for ${{ matrix.deb_arch }}" >&2
exit 1
fi
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "deb=$DEB_FILE" >> "$GITHUB_OUTPUT"
echo "deb=${{ steps.deb.outputs.deb }}" >> "$GITHUB_OUTPUT"
- name: SHA-256 hashes
run: |
+18
View File
@@ -40,6 +40,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`fipstop` as "Own Loopback". `req_duplicate` returns to meaning only what it
says.
#### Packaging
- The Linux `.deb` and the systemd tarball now install and run on Debian 12 and
Ubuntu 22.04. Every Linux artifact from v0.3.0 through v0.5.0 was built on the
newest available runner, whose C library made the standard library's `pidfd`
references a hard `GLIBC_2.39` version requirement instead of the weak,
runtime-checked ones it is meant to compile to. The loader refuses an image on
that entry alone, so `fips`, `fipstop` and `fips-gateway` could not start;
`fipsctl` was unaffected, which is why an install that was checked by running
it looked healthy while the daemon was dead. No source code caused this and
none was changed. The Linux artifacts are now built in a container pinned to
the oldest supported distribution, declared with the floor in
`packaging/build-floor.env`, and every producer runs
`testing/check-glibc-floor.sh` on what it made, so a package or a tarball that
would not load fails the build rather than reaching a user. The declared
dependency is derived from the binaries instead of hand-written, so it states
the floor it was built against.
## [0.5.0] - 2026-08-30
### Added
+9 -1
View File
@@ -76,7 +76,15 @@ copyright = "2026 Johnathan Corgan"
license-file = ["LICENSE", "0"]
section = "net"
priority = "optional"
depends = "libc6, systemd, libdbus-1-3"
# "$auto" runs dpkg-shlibdeps over each packaged binary and derives the real
# dependencies, including the libc6 version floor. Written by hand this field
# said only "libc6", which no glibc fails to satisfy, so a package whose
# binaries needed 2.39 installed happily on a system with 2.35 and the daemon
# then could not start. Deriving it also picks up a libdbus floor the hand
# written list lacked, and re-derives per architecture, which matters because
# arm64 links libdbus in all four binaries where amd64 links it in one.
# systemd stays by hand: nothing links it, so nothing can derive it.
depends = "$auto, systemd"
recommends = "bluez"
extended-description = """\
FIPS is a distributed, decentralized network routing protocol for mesh \
+15 -2
View File
@@ -4,7 +4,8 @@
# All outputs are placed in deploy/ at the project root.
#
# Usage:
# make deb Build a Debian/Ubuntu .deb package
# 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 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+)
@@ -19,11 +20,23 @@ SHELL := /bin/bash
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
.PHONY: all deb tarball ipk apk aur pkg freebsd zip clean
.PHONY: all deb deb-host tarball ipk apk aur pkg freebsd zip clean
all: deb tarball
# `deb` builds in the pinned container so the package carries the declared glibc
# floor and can install on every supported distribution. It also checks that
# floor before it hands the package back.
deb:
@bash $(PACKAGING_DIR)/debian/build-deb-container.sh
# `deb-host` builds with whatever toolchain and C library the host has. It is
# for iterating locally and NOT for anything anyone else installs: on a modern
# host it produces a package that installs cleanly and then cannot start on
# Debian 12 or Ubuntu 22.04, which is the defect that made the container build
# necessary. Nothing checks its floor, deliberately, so the check stays
# attached to the artifact that ships.
deb-host:
@bash $(PACKAGING_DIR)/debian/build-deb.sh
tarball:
+22 -1
View File
@@ -7,7 +7,7 @@ and `make apk` write to `dist/` instead.
## Quick Start
```sh
make deb # Debian/Ubuntu .deb
make deb # Debian/Ubuntu .deb (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+)
@@ -18,8 +18,29 @@ make zip # Windows .zip package
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
[build-floor.env](build-floor.env), and checks the package it produced
against that floor before handing it back. 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
shipped one is.
`make deb-host` is the old path. It builds on the host, at whatever glibc
the host has, and it is checked against nothing. Use it for local
iteration only. A package built on a current distribution records a
version dependency that the loader refuses on Debian 12 and Ubuntu 22.04,
which is what shipped in every Linux artifact from v0.3.0 through v0.5.0,
so it must not produce anything anyone else installs.
## Build Prerequisites
The prerequisites below apply to the host-build targets. `make deb` needs
docker and nothing else.
These targets build FIPS from source, so the host needs a build
environment in addition to a Rust toolchain (the version pinned in
`rust-toolchain.toml` is auto-installed by rustup).
+35
View File
@@ -0,0 +1,35 @@
# 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.
#
# 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
#
# Ubuntu 22.04 glibc 2.35 free support ends April 2027
# Debian 12 glibc 2.36 LTS ends 2028-06-30
# Ubuntu 24.04 glibc 2.39
# 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.
#
# Deliberately NOT a GitHub runner label. Runner availability follows GitHub's
# rule of supporting the newest two images; the floor follows distributors'
# support windows. Those are different clocks, and ubuntu-26.04 being in preview
# means the ubuntu-22.04 runner will very likely retire before Canonical's date.
# A container base outlives the runner label and builds the same way on a
# developer's machine, which is what lets local and GitHub CI do identical work.
#
# 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.
FIPS_BUILD_IMAGE="ubuntu:22.04"
# 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"
+49
View File
@@ -0,0 +1,49 @@
# Build image for the Linux release artifacts.
#
# Pinned to the oldest distribution FIPS supports, because the glibc a binary is
# linked against decides the glibc it will run on. See packaging/build-floor.env
# for the floor and the reasoning; BASE is passed from there, not written here,
# so there is one place to change it.
#
# This image carries the toolchain and the build dependencies only. It never
# carries the source: the source is mounted at run time, so editing a file does
# not invalidate the image and a warm rebuild costs seconds rather than minutes.
ARG BASE=ubuntu:22.04
FROM ${BASE}
ENV DEBIAN_FRONTEND=noninteractive
# dpkg-dev is not in a bare ubuntu:22.04 and is what provides dpkg-shlibdeps,
# which cargo-deb's "$auto" dependency resolution shells out to. Without it the
# declared dependencies would silently lose their versions again.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libdbus-1-dev \
libclang-dev \
clang \
binutils \
dpkg-dev \
curl \
ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# The toolchain version is passed in, read from rust-toolchain.toml by the
# calling script, and the image tag carries it -- so the image cannot drift from
# the compiler the rest of CI uses, and bumping the pin rebuilds the image. The
# builder this replaces installed `stable` and never copied rust-toolchain.toml,
# so it compiled with a different compiler from the release and nothing said so.
ARG RUST_TOOLCHAIN
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN test -n "${RUST_TOOLCHAIN}" \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME"
ARG CARGO_DEB_VERSION=3.6.3
RUN cargo install cargo-deb --version "${CARGO_DEB_VERSION}" --locked \
&& chmod -R a+w "$CARGO_HOME"
WORKDIR /src
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# Build the Debian package in the pinned build container, then check its floor.
#
# This is the one place the Linux artifacts are produced. The release workflow,
# the CI integration job and a local run all call it, so all three build the
# same way and a package that passes locally is the package that ships. That was
# not true before: the test suite built its own package inside a Debian 12 image
# while the release built on the newest GitHub runner, so the suite could not
# exhibit a defect that only the release environment produced -- and for five
# releases it did not.
#
# Usage: build-deb-container.sh [--output-dir DIR] [--version V] [--features LIST]
# [--rebuild-image]
#
# Requires docker. The image is cached between runs and rebuilt only when the
# Dockerfile or the floor changes; the source is mounted rather than copied, so
# editing code does not invalidate it.
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"
DEST_DIR="$REPO_ROOT/deploy"
VERSION=""
FEATURES=""
REBUILD_IMAGE=0
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 ;;
--rebuild-image) REBUILD_IMAGE=1; shift ;;
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
command -v docker >/dev/null 2>&1 || {
echo "build-deb-container: docker is required and was not found." >&2
exit 2
}
# Read the toolchain from the pin rather than choosing one here, and put it in
# the tag so a bump rebuilds the image instead of silently reusing a stale one.
RUST_TOOLCHAIN=$(awk -F'"' '/^channel *=/{print $2; exit}' "$REPO_ROOT/rust-toolchain.toml")
[ -n "$RUST_TOOLCHAIN" ] || {
echo "build-deb-container: could not read channel from rust-toolchain.toml" >&2
exit 2
}
IMAGE_TAG="fips-deb-builder:${FIPS_BUILD_IMAGE//[:\/]/-}-rust${RUST_TOOLCHAIN}"
if [ "$REBUILD_IMAGE" -eq 1 ] || ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
echo "=== Building $IMAGE_TAG from $FIPS_BUILD_IMAGE with Rust $RUST_TOOLCHAIN ===" >&2
docker build \
--build-arg "BASE=$FIPS_BUILD_IMAGE" \
--build-arg "RUST_TOOLCHAIN=$RUST_TOOLCHAIN" \
-t "$IMAGE_TAG" \
-f "$SCRIPT_DIR/Dockerfile.build" \
"$SCRIPT_DIR"
else
echo "=== Using cached $IMAGE_TAG ===" >&2
fi
# Derive the version and the timestamp on the host, where git works, and pass
# both in. The container then never runs git, which matters for two reasons: a
# worktree's .git is a file pointing outside the mount and would not resolve,
# and a bind-mounted repository trips git's dubious-ownership check.
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}~dev+git${GIT_DATE}.${GIT_SHA}${DIRTY}-1"
else
VERSION="$CRATE_VERSION"
fi
fi
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$REPO_ROOT" log -1 --format=%ct)}"
mkdir -p "$DEST_DIR"
DEST_ABS="$(cd "$DEST_DIR" && pwd)"
echo "=== Building fips $VERSION in $IMAGE_TAG ===" >&2
# A feature build hands the whole job to build-deb.sh rather than pre-building:
# --features has to reach cargo, and build-deb.sh is also what marks the version
# so a feature package is distinguishable from the default build of the same
# commit. It refuses --features with --no-build for exactly that reason, so the
# two cases cannot share one command.
if [ -n "$FEATURES" ]; then
# build-deb.sh does the whole job here: --features has to reach cargo, and
# it is also what marks the version so a feature package is distinguishable
# from the default build of the same commit. It refuses --features with
# --no-build for that reason, so the two cases cannot share one command.
# The version still comes from the host, because the image has no git.
BUILD_CMD="packaging/debian/build-deb.sh --features '$FEATURES' --version '$VERSION' --output-dir /out"
else
BUILD_CMD="cargo build --release --locked
packaging/debian/build-deb.sh --no-build --version '$VERSION' --output-dir /out"
fi
# The source is mounted read-only so a build cannot leave artifacts in the tree.
# CARGO_TARGET_DIR and the registry live in named volumes, which is what makes a
# second run fast; they are per-base-image so a floor change does not reuse
# objects linked against the wrong C library.
VOL_SUFFIX="${FIPS_BUILD_IMAGE//[:\/]/-}"
docker run --rm \
-v "$REPO_ROOT":/src:ro \
-v "$DEST_ABS":/out \
-v "fips-deb-target-${VOL_SUFFIX}":/target \
-v "fips-deb-registry-${VOL_SUFFIX}":/usr/local/cargo/registry \
-e CARGO_TARGET_DIR=/target \
-e SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
-w /src \
"$IMAGE_TAG" \
bash -euo pipefail -c "$BUILD_CMD" >&2
DEB=$(find "$DEST_ABS" -maxdepth 1 -name "fips_*_*.deb" -newermt '-10 minutes' -print | sort | tail -1)
[ -n "$DEB" ] || { echo "build-deb-container: no .deb was produced." >&2; exit 1; }
# Check the artifact here rather than in one workflow, so every producer is
# gated: the release, the CI job, a local run and packaging/Makefile all reach
# the check through this script.
"$REPO_ROOT/testing/check-glibc-floor.sh" "$DEB" >&2
echo "=== Built $DEB ===" >&2
printf '%s\n' "$DEB"
+30 -8
View File
@@ -23,6 +23,9 @@ Options:
--features <list> Cargo features to build with (comma-separated). Marks the
auto-derived Version so the package is distinguishable
from a default build of the same commit.
--output-dir <dir> Where to put the finished .deb. Defaults to deploy/ under
the project root. Exists so the container build can write
to a mount and leave the source tree read-only.
-h, --help Show this help
EOF
}
@@ -31,6 +34,7 @@ TARGET_TRIPLE=""
VERSION_OVERRIDE=""
NO_BUILD=0
FEATURES=""
DEST_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -50,6 +54,10 @@ while [[ $# -gt 0 ]]; do
FEATURES="${2:?missing value for --features}"
shift 2
;;
--output-dir)
DEST_DIR="${2:?missing value for --output-dir}"
shift 2
;;
-h|--help)
usage
exit 0
@@ -124,9 +132,20 @@ if [[ -z "${VERSION_OVERRIDE}" ]]; then
echo "Auto-derived dev Version: ${VERSION_OVERRIDE}"
fi
elif [[ -n "${FEATURES}" ]]; then
echo "Warning: --version was given with --features, so the Version carries no" >&2
echo "feature marker and this package is indistinguishable from a default" >&2
echo "build of the same commit. Mark it yourself if that matters." >&2
# An explicit version needs the same marker for the same reason, and it is
# the only way a caller that cannot derive the version here can get one.
# The container build is that caller: it derives the version on the host
# because the image has no git, and the source is mounted read-only from a
# worktree whose .git is a file pointing outside the mount.
if [[ "${VERSION_OVERRIDE}" == *"+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')"* ]]; then
: # already marked by the caller
elif [[ "${VERSION_OVERRIDE}" == *-* ]]; then
# Split off the Debian revision so the marker lands on the upstream part.
VERSION_OVERRIDE="${VERSION_OVERRIDE%-*}+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')-${VERSION_OVERRIDE##*-}"
else
VERSION_OVERRIDE="${VERSION_OVERRIDE}+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')"
fi
echo "Feature-marked Version: ${VERSION_OVERRIDE}"
fi
# Build the .deb package
@@ -149,8 +168,11 @@ if [[ -n "${FEATURES}" ]]; then
fi
cargo "${cargo_args[@]}"
# Move output to deploy/
mkdir -p deploy
# Move output to the requested directory, or deploy/ by default. Note the
# distinction from OUTPUT_DIR above, which is cargo-deb's temporary staging
# directory and is removed by the EXIT trap.
: "${DEST_DIR:=deploy}"
mkdir -p "${DEST_DIR}"
DEB_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
if [ -z "${DEB_FILE}" ]; then
@@ -158,10 +180,10 @@ if [ -z "${DEB_FILE}" ]; then
exit 1
fi
cp "${DEB_FILE}" deploy/
cp "${DEB_FILE}" "${DEST_DIR}/"
BASENAME=$(basename "${DEB_FILE}")
echo "Package built: deploy/${BASENAME}"
echo "Package built: ${DEST_DIR}/${BASENAME}"
echo ""
echo "Install with: sudo dpkg -i deploy/${BASENAME}"
echo "Install with: sudo dpkg -i ${DEST_DIR}/${BASENAME}"
echo "Remove with: sudo dpkg -r fips"
echo "Purge with: sudo dpkg -P fips (removes config and identity keys)"
+3 -2
View File
@@ -125,11 +125,12 @@ machines. Not a Docker harness.
[`ci-local.sh`](ci-local.sh) runs the full local CI pipeline — build,
clippy, unit tests, and the integration suites (including the chaos
scenarios) — mirroring the GitHub `ci.yml` integration matrix. Run
scenarios) — mirroring the GitHub `ci.yml` integration matrices. Run
`./ci-local.sh --help` for the full option list and `--list` for the
available suites. Every run starts with a parity check that verifies the
local suite set covers the same work as the GitHub matrix, per scenario for
chaos and per distro for deb-install; a divergence fails the run. GitHub
chaos and per distro for deb-install, across every job that carries a
matrix; a divergence fails the run. GitHub
runs the same check as its own `ci-parity` job. `--check-parity` runs it
alone (see [check-ci-parity.sh](check-ci-parity.sh)).
+23 -8
View File
@@ -1,10 +1,10 @@
#!/bin/bash
# ── CI parity invariant guard ───────────────────────────────────────────────
# The GitHub integration matrix (.github/workflows/ci.yml) and the local
# default suite set (ci-local.sh) MUST run the same integration suites,
# EXCEPT for the deliberate local-only entries listed below. Adding a suite
# to one runner without the other means "local green" and "GitHub green" stop
# being equivalent claims.
# The GitHub integration matrices (.github/workflows/ci.yml, swept across every
# job) and the local default suite set (ci-local.sh) MUST run the same
# integration suites, EXCEPT for the deliberate local-only entries listed below.
# Adding a suite to one runner without the other means "local green" and
# "GitHub green" stop being equivalent claims.
#
# Deliberate local-only (NOT on the GitHub gate), with reason:
# tor-socks5 — requires live Tor network; opt-in via --with-tor,
@@ -19,8 +19,9 @@
# names differ cosmetically between runners and are ignored
# — `scenario:` is the identity.
# deb-install — per distro. GitHub splits into per-distro legs carrying
# `scenario:`; local runs the same distro set in one suite,
# enumerated by ALL_SCENARIOS in deb-install/test.sh.
# `scenario:`, in a job of their own; local runs the same
# distro set in one suite, enumerated by ALL_SCENARIOS in
# deb-install/test.sh.
# everything else — per suite name.
#
# dns-resolver is the one leg still compared at leg granularity rather than
@@ -134,7 +135,21 @@ for name, entries in arrays.items():
with open(ci_yml_path, encoding="utf-8") as fh:
doc = yaml.safe_load(fh)
include = doc["jobs"]["integration"]["strategy"]["matrix"]["include"]
# Sweep every job's matrix rather than one named job: the install legs live in
# a job of their own so the rest of the integration matrix does not wait on the
# package build, and a guard keyed to a job name reports them as local-only the
# moment they move. Identity comes from the leg's own fields, so where a leg
# lives does not matter; a leg deleted from every job still shows up as
# local-only, because the local side is the reference.
include = []
for job in (doc.get("jobs") or {}).values():
legs = (((job.get("strategy") or {}).get("matrix") or {}).get("include") or [])
if isinstance(legs, list):
include += [leg for leg in legs if isinstance(leg, dict)]
if not include:
print("check-ci-parity: no matrix include: found in any job of "
f"{ci_yml_path}; cannot verify CI parity", file=sys.stderr)
sys.exit(2)
github_chaos, github_deb, github = {}, set(), set()
malformed = []
for leg in include:
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Fail when a shipped binary needs a newer glibc than the declared floor.
#
# The defect this exists to catch installs cleanly and then cannot run. Rust's
# standard library references pidfd_spawnp and pidfd_getpid as weak undefined
# symbols guarded by a runtime check, so a binary is meant to fall back where
# the C library lacks them. Linking against a glibc that HAS them records a
# version dependency instead, and the loader refuses the whole image on that
# entry alone regardless of the symbols being weak. Every Linux artifact from
# v0.3.0 to v0.5.0 shipped that way and could not start on Debian 12 or Ubuntu
# 22.04, while apt reported success and fipsctl -- which never spawns a process
# and so never referenced those symbols -- ran perfectly.
#
# Usage: check-glibc-floor.sh <artifact|binary>...
# A .deb is unpacked and every executable under usr/bin is checked.
# Anything else is treated as a single ELF binary.
#
# 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}"
for tool in readelf dpkg dpkg-deb; do
command -v "$tool" >/dev/null 2>&1 || {
echo "check-glibc-floor: $tool is not installed; cannot check anything." >&2
echo " Refusing to report a pass I did not establish." >&2
exit 2
}
done
# The maximum glibc version a binary requires.
#
# Reads the `Version needs` section, which is the table the dynamic loader
# enforces and the one that carries the fatal entry. Do NOT compute this from
# `objdump -T | grep GLIBC_ | sort -V | tail -1`: that sorts whole lines, the
# version is not the leading field, and a data symbol at GLIBC_2.2.5 therefore
# sorts last. Measured against the shipped v0.5.0 fips binary, that pipeline
# reports 2.2.5 for a binary whose real floor is 2.39 -- it would have passed
# every affected release.
#
# Prints nothing and returns 1 when it finds no GLIBC requirement at all, so an
# unreadable or non-dynamic input cannot be scored as a pass.
max_glibc_need() {
local bin="$1" found
found=$(readelf -VW "$bin" 2>/dev/null \
| awk '/Version needs section/,0' \
| grep -oE 'GLIBC_[0-9.]+' \
| sed 's/GLIBC_//' \
| sort -V \
| tail -1) || true
[ -n "$found" ] || return 1
printf '%s\n' "$found"
# Explicit: the caller tests this status to tell "no requirement" from a
# value, so it must not be whatever printf happened to return.
return 0
}
FAILED=0
CHECKED=0
is_elf() { readelf -hW "$1" >/dev/null 2>&1; }
check_binary() {
local bin="$1" label="$2" need
if ! need=$(max_glibc_need "$bin"); then
# A static binary is a legitimate no-requirement case, so distinguish
# it from a file that could not be read rather than passing both.
if readelf -hW "$bin" >/dev/null 2>&1; then
echo " ok $label (no glibc version requirement)"
CHECKED=$((CHECKED + 1))
return
fi
echo " ERROR $label is not a readable ELF object" >&2
FAILED=$((FAILED + 1))
return
fi
CHECKED=$((CHECKED + 1))
if dpkg --compare-versions "$need" gt "$FLOOR"; then
echo " FAIL $label needs glibc $need, above the declared floor $FLOOR" >&2
FAILED=$((FAILED + 1))
else
echo " ok $label needs glibc $need"
fi
}
check_deb() {
local deb="$1" tmp
tmp=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" RETURN
dpkg-deb -x "$deb" "$tmp"
# A package legitimately ships executable shell scripts alongside its
# binaries -- fips-dns-setup and its teardown are two -- so filter to ELF
# objects rather than treating a script as an unreadable binary. A .deb
# with no ELF object at all is still an error: it means the glob or the
# layout moved and this check examined nothing.
local found=0 f
while IFS= read -r -d '' f; do
is_elf "$f" || continue
found=1
check_binary "$f" "$(basename "$deb"):$(basename "$f")"
done < <(find "$tmp" -type f -perm -u+x -print0)
if [ "$found" -eq 0 ]; then
echo " ERROR $(basename "$deb") contains no ELF executables" >&2
FAILED=$((FAILED + 1))
fi
}
[ $# -gt 0 ] || {
echo "usage: check-glibc-floor.sh <artifact|binary>..." >&2
exit 2
}
echo "=== glibc floor check (declared floor: $FLOOR) ==="
for arg in "$@"; do
[ -e "$arg" ] || { echo " ERROR $arg does not exist" >&2; FAILED=$((FAILED + 1)); continue; }
case "$arg" in
*.deb) check_deb "$arg" ;;
*) check_binary "$arg" "$(basename "$arg")" ;;
esac
done
# Nothing examined is a failure, not a pass. An argument list that matched no
# binary means the caller's glob went stale, and reporting that as green is how
# a guard quietly stops guarding.
if [ "$CHECKED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then
echo "check-glibc-floor: examined no binaries; refusing to report a pass." >&2
exit 2
fi
if [ "$FAILED" -ne 0 ]; then
echo "check-glibc-floor: $FAILED problem(s) across $CHECKED binaries." >&2
echo " A binary above the floor installs cleanly and then fails to start." >&2
echo " Build through packaging/debian/build-deb-container.sh, which pins" >&2
echo " the build image to the oldest supported distribution." >&2
exit 1
fi
echo "=== glibc floor check passed ($CHECKED binaries, all at or below $FLOOR) ==="
+55 -5
View File
@@ -999,13 +999,63 @@ run_dns_resolver() {
}
# Run deb-install harness (multi-distro real-package install)
#
# Bounded because this worker is what gates artifact publication: a suite that
# hangs stops every branch publishing, which is worse than a red. The harness
# bounds its own service starts now, so this is the backstop for anything else
# that wedges -- a docker daemon that stops answering, a container that never
# boots. 40 minutes is well above the observed cold-cache cost of a full
# five-distro run and is not a performance budget.
#
# It bounds the container build and the five installs separately rather than
# both together: the budget was sized for the installs, and a cold build that
# ate into it would shrink theirs. Neither phase is left unbounded, which is
# the property this exists for.
DEB_INSTALL_TIMEOUT=${DEB_INSTALL_TIMEOUT:-2400}
run_deb_install() {
info "[deb-install] Running multi-distro test (slow — builds .deb + per-distro install)"
if bash testing/deb-install/test.sh 2>&1; then
record "deb-install" 0
else
record "deb-install" 1
# Build once through the shared container script, then install that one
# artifact into every distro. The harness would build its own package if
# handed none, and that fallback goes through the same script -- but the
# GitHub job builds explicitly and passes `--deb`, so doing it explicitly
# here too makes the two systems read as the same work rather than leaving
# a reader to discover that a fallback happens to match.
info "[deb-install] Building the package in the pinned container"
local build_log deb rc=0
build_log=$(mktemp "/tmp/ci-deb-install-build.XXXXXX")
# stdout carries the package path on its last line, so it is captured;
# stderr stays on the console so build progress is still visible live.
timeout "$DEB_INSTALL_TIMEOUT" \
bash packaging/debian/build-deb-container.sh | tee "$build_log" || rc=$?
if [[ $rc -ne 0 ]]; then
if [[ $rc -eq 124 ]]; then
echo " ERROR: the container build exceeded ${DEB_INSTALL_TIMEOUT}s and was killed;" >&2
echo " no package was produced, so this is not an assertion failure." >&2
else
echo " ERROR: the container build failed (exit $rc); no package to install." >&2
fi
rm -f "$build_log"
record "deb-install" "$rc"
return
fi
deb=$(tail -n 1 "$build_log")
rm -f "$build_log"
if [[ -z "$deb" || ! -f "$deb" ]]; then
echo " ERROR: the container build reported success but its last line of stdout" >&2
echo " was not a package path: '${deb}'" >&2
record "deb-install" 1
return
fi
info "[deb-install] Running multi-distro install test against $deb"
rc=0
timeout "$DEB_INSTALL_TIMEOUT" bash testing/deb-install/test.sh --deb "$deb" 2>&1 || rc=$?
if [[ $rc -eq 124 ]]; then
# Say so explicitly. A bare red here reads as a failed assertion, and
# the difference matters: a timeout means no assertion was reached.
echo " ERROR: deb-install exceeded ${DEB_INSTALL_TIMEOUT}s and was killed;" >&2
echo " no verdict was reached, so this is not an assertion failure." >&2
fi
record "deb-install" "$rc"
}
# Run Tor SOCKS5 outbound test (live Tor network)
+129 -40
View File
@@ -36,11 +36,26 @@ DEB_CACHE_DIR="$CACHE_DIR/deb"
BOOT_TIMEOUT=30
SERVICE_TIMEOUT=20
DAEMON_TIMEOUT=15
# Bounds on a single `systemctl start`, which is not a wait loop and needs its
# own limit. See start_unit() for why an unbounded one can never return.
UNIT_START_TIMEOUT=30
# fips-gateway.service's ExecStartPre waits up to 30s for fips0 to appear, by
# design, so its start legitimately takes longer than any other.
GATEWAY_START_TIMEOUT=60
# The gateway-enable block restarts fips.service inside the container. Above
# systemd's default TimeoutStopSec of 90s, so a wedged stop trips this rather
# than this cutting a healthy stop short.
CONFIG_RESTART_TIMEOUT=120
PASS=0
FAIL=0
SKIP=0
# Set by --deb. DEB_PREPARED keeps the copy-into-cache to a single operation
# however many scenarios run in one process.
SUPPLIED_DEB=""
DEB_PREPARED=0
# ─────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────
@@ -97,6 +112,41 @@ wait_for_systemd() {
return 1
}
# Start a unit without waiting for its start job to finish.
#
# Start a unit and wait for its start job, under a bound.
#
# Blocking is the right default and the call returning is what synchronises the
# checks after it: `fips-gateway.service` in particular has an ExecStartPre that
# waits up to 30s for fips0, so a caller that does not wait races it. What the
# old code lacked was the bound, not the wait.
start_unit() {
local name="$1" unit="$2" limit="${3:-$UNIT_START_TIMEOUT}"
timeout "$limit" docker exec "$name" systemctl start "$unit" 2>&1
}
# Queue a unit's start job and return without waiting for it.
#
# For `fips-dns.service` only, and the reason is specific rather than general.
# It is Type=oneshot with Requires=fips.service, so its start job waits on a
# dependency that a broken daemon never satisfies: fips.service restarts every
# 5s for ever and the oneshot's job is never dispatched. `systemctl start` then
# never returns. That is the whole class of fault this suite exists to find, and
# the suite answered it by hanging -- no FAIL, no Results line, no exit status,
# observed at 21 minutes against a package whose binaries could not load.
#
# Queueing moves the verdict onto the wait_for_service_active call that follows,
# which carries a timeout and dumps the journal when it fails. RemainAfterExit=yes
# on that unit makes `is-active` a correct readiness test for a oneshot.
#
# This bounds these call sites, not every `docker exec` in the file. The backstop
# for the rest is the caller's own limit: ci-local.sh bounds the whole suite, and
# the GitHub leg carries timeout-minutes.
start_unit_queued() {
local name="$1" unit="$2"
timeout "$UNIT_START_TIMEOUT" docker exec "$name" systemctl start --no-block "$unit" 2>&1
}
wait_for_service_active() {
local name="$1" service="$2" timeout="${3:-$SERVICE_TIMEOUT}"
for _i in $(seq 1 "$timeout"); do
@@ -124,6 +174,25 @@ container_systemd_version() {
build_deb() {
mkdir -p "$DEB_CACHE_DIR"
# A supplied package wins outright and bypasses the staleness check below.
# That check compares mtimes, so a cached package could otherwise beat the
# artifact the caller explicitly handed over, which is exactly the silent
# substitution this suite exists to stop making.
if [ -n "$SUPPLIED_DEB" ]; then
if [ "$DEB_PREPARED" -eq 1 ]; then
return 0
fi
if [ ! -f "$SUPPLIED_DEB" ]; then
echo " ERROR: --deb $SUPPLIED_DEB does not exist" >&2
return 1
fi
rm -f "$DEB_CACHE_DIR"/*.deb
cp "$SUPPLIED_DEB" "$DEB_CACHE_DIR/"
DEB_PREPARED=1
log "Installing the supplied package $(basename "$SUPPLIED_DEB")"
return 0
fi
local cached_deb
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
@@ -143,45 +212,25 @@ build_deb() {
log "No cached .deb, building"
fi
local builder_tag="fips-deb-test:builder"
log "Building Debian 12 cargo-deb builder image (slow on first run)"
docker build -t "$builder_tag" -f - "$REPO_ROOT" <<'DOCKERFILE' >/dev/null
FROM debian:12
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libdbus-1-dev curl ca-certificates \
libclang-dev clang && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install cargo-deb --version 3.6.3 --locked
WORKDIR /src
COPY Cargo.toml Cargo.lock build.rs LICENSE README.md ./
COPY src ./src
COPY packaging ./packaging
COPY docs ./docs
RUN cargo build --release && cargo deb --no-build
DOCKERFILE
if [ ! "$(docker images -q "$builder_tag" 2>/dev/null)" ]; then
echo " ERROR: builder image build failed"
# Build through the shared container script rather than a builder defined
# here. This suite used to compile its own package inside a Debian 12 image
# while the release compiled on the newest GitHub runner, so the package it
# tested had a lower glibc floor than the package users received and could
# not exhibit a defect that only the release environment produced. It stayed
# green through five releases that could not start on two of the five
# distributions in its own matrix.
log "Building the .deb in the pinned build container (slow on first run)"
if ! bash "$REPO_ROOT/packaging/debian/build-deb-container.sh" \
--output-dir "$DEB_CACHE_DIR" >&2; then
echo " ERROR: container build failed" >&2
return 1
fi
log "Extracting .deb from builder image"
rm -f "$DEB_CACHE_DIR"/*.deb
local cid
cid=$(docker create "$builder_tag")
docker cp "$cid:/src/target/debian/." "$DEB_CACHE_DIR/" >/dev/null 2>&1
docker rm "$cid" >/dev/null
# Cargo-deb leaves intermediate artifacts; keep just the .deb.
find "$DEB_CACHE_DIR" -mindepth 1 -not -name 'fips_*_amd64.deb' -delete 2>/dev/null || true
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
if [ -n "$cached_deb" ]; then
log "Cached at $cached_deb ($(stat -c %s "$cached_deb") bytes)"
else
echo " ERROR: no .deb produced by cargo-deb"
echo " ERROR: no .deb produced by the container build" >&2
return 1
fi
}
@@ -349,8 +398,8 @@ DOCKERFILE
# Start the services as a simulated boot. (On a real system,
# they'd come up on next reboot.)
docker exec "$name" systemctl start fips.service 2>&1 || true
docker exec "$name" systemctl start fips-dns.service 2>&1 || true
start_unit "$name" fips.service || true
start_unit_queued "$name" fips-dns.service || true
if wait_for_service_active "$name" fips.service; then
pass "fips.service active after explicit start"
@@ -381,10 +430,21 @@ DOCKERFILE
return
fi
# Wait for fips-dns.service. This should have run fips-dns-setup
# which configures the resolver backend and writes
# /run/fips/dns-backend.
sleep 2
# Wait for fips-dns.service to finish. Its start job is queued rather than
# waited on above, so this is what makes /run/fips/dns-backend safe to read:
# fips-dns-setup waits up to 30s for fips0 and restarts systemd-resolved
# before it writes that file, so reading it on a timer races the setup.
# RemainAfterExit=yes makes is-active correct for this oneshot.
if wait_for_service_active "$name" fips-dns.service; then
pass "fips-dns.service completed"
else
fail "fips-dns.service did not complete in ${SERVICE_TIMEOUT}s"
echo " --- fips-dns.service journal ---"
docker exec "$name" journalctl -u fips-dns.service --no-pager 2>&1 | tail -20
cleanup_container "$name"
return
fi
local backend
backend=$(docker exec "$name" cat /run/fips/dns-backend 2>/dev/null || echo "(missing)")
local ver
@@ -441,7 +501,7 @@ DOCKERFILE
# default preset) and ipv6 forwarding (gateway checks before
# the DNS upstream check).
docker exec "$name" sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
docker exec "$name" bash -c '
timeout "$CONFIG_RESTART_TIMEOUT" docker exec "$name" bash -c '
systemctl unmask fips-gateway.service 2>/dev/null
# Patch in a minimal gateway config since the shipped fips.yaml
# has gateway disabled by default.
@@ -482,7 +542,7 @@ EOF
fail "non-root fips group member cannot reach control socket after restart"
fi
docker exec "$name" systemctl start fips-gateway.service >/dev/null 2>&1 || true
start_unit "$name" fips-gateway.service "$GATEWAY_START_TIMEOUT" >/dev/null 2>&1 || true
sleep 3
if docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null \
| grep -q "DNS upstream is reachable"; then
@@ -509,6 +569,35 @@ test_ubuntu26() { _run_deb_install_scenario ubuntu26 ubuntu:26.04; }
ALL_SCENARIOS="debian12 debian13 ubuntu22 ubuntu24 ubuntu26"
# `--deb PATH` installs a package the caller already built, which is how one
# build serves all five distributions and how GitHub CI and a local run come to
# do the same work: both build once through the container script and hand the
# result here. Keep ALL_SCENARIOS above on its own line at column zero;
# check-ci-parity.sh reads it to compare this matrix against the GitHub one.
_args=()
while [ $# -gt 0 ]; do
case "$1" in
--deb)
SUPPLIED_DEB="${2:?--deb requires a path}"
shift 2
;;
-h|--help)
echo "usage: test.sh [--deb PATH] [scenario ...]"
echo "scenarios: $ALL_SCENARIOS"
exit 0
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
_args+=("$1")
shift
;;
esac
done
set -- ${_args[@]+"${_args[@]}"}
if [ $# -eq 0 ]; then
scenarios="$ALL_SCENARIOS"
else