From 1839de57db26bcf61e03ccb384e1bbe0818e588b Mon Sep 17 00:00:00 2001 From: mervin008 Date: Sat, 10 Oct 2026 19:58:53 +0200 Subject: [PATCH] Build deb and rpm packages with systemd units Each release now has a deb and an rpm for every Linux architecture. They install the binary, a service and a timer, and enable neither until the host has enrolled. CI installs them on Debian 12 and Rocky Linux 9. --- .github/workflows/ci.yml | 2 + README.md | 3 +- docs/agent.md | 19 ++++++++- packaging/certpilot-agent-once.service | 10 +++++ packaging/certpilot-agent.service | 17 +++++++++ packaging/certpilot-agent.timer | 12 ++++++ packaging/nfpm.yaml | 39 +++++++++++++++++++ packaging/postinstall.sh | 7 ++++ packaging/preremove.sh | 6 +++ scripts/build-release.sh | 23 ++++++++++- scripts/verify-packages.sh | 53 ++++++++++++++++++++++++++ 11 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 packaging/certpilot-agent-once.service create mode 100644 packaging/certpilot-agent.service create mode 100644 packaging/certpilot-agent.timer create mode 100644 packaging/nfpm.yaml create mode 100644 packaging/postinstall.sh create mode 100644 packaging/preremove.sh create mode 100755 scripts/verify-packages.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c8d311..4906968 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: - run: ./scripts/build-release.sh dev dist - name: the checksums match the files run: cd dist && sha256sum -c SHA256SUMS + - name: the deb and rpm install on Debian and Rocky Linux + run: ./scripts/verify-packages.sh dist race: name: race detector diff --git a/README.md b/README.md index e9dfad2..6410282 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ go install github.com/certpilot/certpilot-agent/cmd@latest No Go or Docker on the host? Each [release](https://github.com/certpilot/certpilot-agent/releases) has a static binary for Linux (amd64, arm64, armv7, 386), Windows (amd64, arm64), -macOS and FreeBSD, with a `SHA256SUMS` file to check it against. +macOS and FreeBSD, a `.deb` and `.rpm` for each Linux architecture, and a +`SHA256SUMS` file to check them against. ``` docker run --rm -v certpilot-agent:/var/lib/certpilot-agent \ diff --git a/docs/agent.md b/docs/agent.md index 85d6122..7954ee7 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -572,8 +572,23 @@ certpilot-agent run --once # one cycle, then exit — for cron or a unit certpilot-agent status # what this host holds and when it last reported ``` -As a systemd unit — see [Where it runs](#where-it-runs) for the platforms -this is supported on: +The `.deb` and `.rpm` from a release install the binary to +`/usr/bin/certpilot-agent` with two ways to run it, neither enabled until you +choose one after enrolling: + +```bash +sudo certpilot-agent enrol --server=https://certpilot.example.com --token=... +sudo systemctl enable --now certpilot-agent # the daemon +# or +sudo systemctl enable --now certpilot-agent.timer # one cycle every 5 minutes +``` + +Both units do nothing until `/var/lib/certpilot-agent/agent.json` exists. +Removing the package stops them and leaves the identity in place, so a +reinstall does not need a new enrolment. + +Installed by hand, as a systemd unit — see [Where it runs](#where-it-runs) for +the platforms this is supported on: ```ini [Unit] diff --git a/packaging/certpilot-agent-once.service b/packaging/certpilot-agent-once.service new file mode 100644 index 0000000..85aa690 --- /dev/null +++ b/packaging/certpilot-agent-once.service @@ -0,0 +1,10 @@ +[Unit] +Description=CertPilot agent, one cycle +Documentation=https://github.com/certpilot/certpilot-agent +After=network-online.target +Wants=network-online.target +ConditionPathExists=/var/lib/certpilot-agent/agent.json + +[Service] +Type=oneshot +ExecStart=/usr/bin/certpilot-agent run --once diff --git a/packaging/certpilot-agent.service b/packaging/certpilot-agent.service new file mode 100644 index 0000000..adf727a --- /dev/null +++ b/packaging/certpilot-agent.service @@ -0,0 +1,17 @@ +[Unit] +Description=CertPilot agent +Documentation=https://github.com/certpilot/certpilot-agent +After=network-online.target +Wants=network-online.target +# Nothing to do until this host has enrolled, and a unit that fails every 30 +# seconds until then would bury the one log line that says why. +ConditionPathExists=/var/lib/certpilot-agent/agent.json + +[Service] +Type=simple +ExecStart=/usr/bin/certpilot-agent run +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/packaging/certpilot-agent.timer b/packaging/certpilot-agent.timer new file mode 100644 index 0000000..42dd7b3 --- /dev/null +++ b/packaging/certpilot-agent.timer @@ -0,0 +1,12 @@ +# For hosts where a resident daemon is not allowed. Enable this or +# certpilot-agent.service, not both. +[Unit] +Description=Run the CertPilot agent every 5 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min +Unit=certpilot-agent-once.service + +[Install] +WantedBy=timers.target diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..dc79aa0 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,39 @@ +# Built by scripts/build-release.sh, once per Linux architecture. nfpm does not +# expand variables in a src path, so the script writes the binary's path over +# @BINARY@ in a copy of this file. +name: certpilot-agent +arch: ${NFPM_ARCH} +platform: linux +version: ${NFPM_VERSION} +maintainer: CertPilot +description: | + The CertPilot host agent. Generates its own private keys, requests + certificates for this host and installs them where its services read them. +homepage: https://github.com/certpilot/certpilot-agent +license: Apache-2.0 + +contents: + - src: "@BINARY@" + dst: /usr/bin/certpilot-agent + file_info: + mode: 0755 + - src: packaging/certpilot-agent.service + dst: /usr/lib/systemd/system/certpilot-agent.service + - src: packaging/certpilot-agent-once.service + dst: /usr/lib/systemd/system/certpilot-agent-once.service + - src: packaging/certpilot-agent.timer + dst: /usr/lib/systemd/system/certpilot-agent.timer + # The identity key lives here, so nobody else may list it. + - dst: /var/lib/certpilot-agent + type: dir + file_info: + mode: 0700 + # Where installs.json is read from by default. + - dst: /etc/certpilot + type: dir + file_info: + mode: 0755 + +scripts: + postinstall: packaging/postinstall.sh + preremove: packaging/preremove.sh diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh new file mode 100644 index 0000000..65ea2a5 --- /dev/null +++ b/packaging/postinstall.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Make systemd see the units. Nothing is enabled or started: the host has to +# enrol first, and which of the service or the timer to use is the operator's +# choice. +if [ -d /run/systemd/system ]; then + systemctl daemon-reload >/dev/null 2>&1 || true +fi diff --git a/packaging/preremove.sh b/packaging/preremove.sh new file mode 100644 index 0000000..aa7a466 --- /dev/null +++ b/packaging/preremove.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Stop what is running before the binary goes. The identity in +# /var/lib/certpilot-agent is left: removing a package must not un-enrol a host. +if [ -d /run/systemd/system ]; then + systemctl disable --now certpilot-agent.service certpilot-agent.timer >/dev/null 2>&1 || true +fi diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 8358983..ae732cb 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # -# Build the agent for every platform it is released for. +# Build the agent for every platform it is released for, and a deb and an rpm +# for each Linux architecture. # # ./scripts/build-release.sh 0.2.4 dist stamp 0.2.4, write archives to dist/ # ./scripts/build-release.sh dev dist unstamped, as CI builds it @@ -31,12 +32,23 @@ if [ "$version" != dev ]; then ldflags="$ldflags -X github.com/certpilot/certpilot-agent.Version=$version" fi +# Pinned, and built rather than downloaded, so the packages come from a known +# version of the tool on any machine with Go. +nfpm_version=v2.46.3 + rm -rf "$out" mkdir -p "$out" out="$(cd "$out" && pwd)" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT +GOBIN="$work/bin" go install "github.com/goreleaser/nfpm/v2/cmd/nfpm@$nfpm_version" + +# A package version has to start with a digit; an unstamped build sorts below +# every release. +pkg_version="$version" +[ "$version" = dev ] && pkg_version=0.0.0-dev + for target in "${targets[@]}"; do IFS=/ read -r goos goarch goarm <<<"$target" name="certpilot-agent_${version}_${goos}_${goarch}${goarm:+v$goarm}" @@ -54,9 +66,16 @@ for target in "${targets[@]}"; do else tar -C "$work" -czf "$out/$name.tar.gz" "$name" fi + if [ "$goos" = linux ]; then + sed "s|@BINARY@|$dir/$bin|" packaging/nfpm.yaml > "$work/nfpm.yaml" + for format in deb rpm; do + NFPM_ARCH="$goarch${goarm:-}" NFPM_VERSION="$pkg_version" \ + "$work/bin/nfpm" package --config "$work/nfpm.yaml" --packager "$format" --target "$out/" >/dev/null + done + fi echo "built $name" done sum() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } -(cd "$out" && sum certpilot-agent_* > SHA256SUMS) +(cd "$out" && sum certpilot-agent[_-]* > SHA256SUMS) echo "wrote $(ls "$out" | wc -l | tr -d ' ') files to $out" diff --git a/scripts/verify-packages.sh b/scripts/verify-packages.sh new file mode 100755 index 0000000..1ff2ba1 --- /dev/null +++ b/scripts/verify-packages.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Install the built deb and rpm in real distributions and check what they put +# on the host. +# +# ./scripts/verify-packages.sh dist after build-release.sh wrote dist/ +# +# Not run under systemd: a container has none. What is checked is what a +# package can get wrong without anyone noticing until a host is enrolled: the +# binary, the units and whether systemd accepts them, the directories and their +# modes, that nothing starts before enrolment, and that removal cleans up. +# +set -uo pipefail + +cd "$(dirname "$0")/.." +dist="${1:?usage: verify-packages.sh DIST}" +dist="$(cd "$dist" && pwd)" +fail=0 + +check() { + local image="$1" prep="$2" install="$3" remove="$4" pkg="$5" + echo "── $image: $pkg" + if ! docker run --rm --platform linux/amd64 -v "$dist:/dist:ro" "$image" bash -euc " + $prep >/dev/null 2>&1 + $install /dist/$pkg >/dev/null + v=\$(certpilot-agent version); echo \" version \$v\" + for u in certpilot-agent.service certpilot-agent-once.service certpilot-agent.timer; do + test -f /usr/lib/systemd/system/\$u || { echo \" missing \$u\"; exit 1; } + done + systemd-analyze verify /usr/lib/systemd/system/certpilot-agent*.service /usr/lib/systemd/system/certpilot-agent.timer + echo ' systemd accepts the units' + test \"\$(stat -c %a /var/lib/certpilot-agent)\" = 700 || { echo ' state dir is not 0700'; exit 1; } + test -d /etc/certpilot || { echo ' no /etc/certpilot'; exit 1; } + grep -q '^ConditionPathExists=/var/lib/certpilot-agent/agent.json' /usr/lib/systemd/system/certpilot-agent.service \ + || { echo ' the service would start before enrolment'; exit 1; } + $remove certpilot-agent >/dev/null + ! command -v certpilot-agent >/dev/null || { echo ' binary left after removal'; exit 1; } + echo ' installs, and removes cleanly' + "; then + echo " FAILED"; fail=1 + fi +} + +deb=$(cd "$dist" && ls certpilot-agent_*_amd64.deb 2>/dev/null | head -1) +rpm=$(cd "$dist" && ls certpilot-agent-*.x86_64.rpm 2>/dev/null | head -1) +[ -n "$deb" ] || { echo "no amd64 .deb in $dist"; exit 1; } +[ -n "$rpm" ] || { echo "no x86_64 .rpm in $dist"; exit 1; } + +# systemd is installed only so systemd-analyze can check the units. +check debian:12 "apt-get update -qq && apt-get install -y -qq systemd" "apt-get install -y -qq" "apt-get remove -y -qq" "$deb" +check rockylinux:9 "dnf install -y -q systemd" "dnf install -y -q" "dnf remove -y -q" "$rpm" + +exit $fail