Relay: its own image, binaries and signatures at release (RL-31) #271

Merged
jcoffey-dev merged 2 commits from relay/packaging into main 2026-10-31 00:43:34 +00:00
7 changed files with 449 additions and 16 deletions

No files matched your search

+140 -5
View File
@@ -45,8 +45,17 @@
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
# own token is refused by the container registry.
#
# inbuxa Relay (relay spec, RL-31) ships from the same tag: publish-relay
# builds its image (Dockerfile.relay) for both platforms, binaries takes its
# static binaries out too, and relay-sign signs them with the inbuxa release
# key, the secret RELAY_RELEASE_KEY, which only this forge holds
# (tools/release/new-relay-keys.sh makes it). relay-sign runs whichever
# forge built the release, once the binaries are on it. Without the secret
# it warns and signs nothing, and no relay takes those binaries as an
# upgrade (RL-29).
#
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every
# job here but the announcement skips, and the tag is published by
# job here but the announcement and relay-sign skips, and the tag is published by
# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same
# tags, the same Release and binaries, created here through the API. The
# `github` job waits for that run's commit status, "github/ci (tag)", and the
@@ -68,6 +77,7 @@ jobs:
version: ${{ steps.v.outputs.version }}
tags: ${{ steps.v.outputs.tags }}
prerelease: ${{ steps.v.outputs.prerelease }}
cargo: ${{ steps.v.outputs.cargo }}
steps:
# Full history: the ancestry check cannot be answered from a shallow
# clone. The checkout also fetches every branch as origin/*.
@@ -93,6 +103,7 @@ jobs:
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "tags=${tags% }" >> "$GITHUB_OUTPUT"
echo "prerelease=$pre" >> "$GITHUB_OUTPUT"
echo "cargo=$(python3 tools/release/version.py --cargo)" >> "$GITHUB_OUTPUT"
echo "version $V; image tags: ${tags% }; prerelease: $pre"
publish-amd64:
@@ -175,6 +186,63 @@ jobs:
- if: always()
run: docker logout "$REGISTRY" || true
# The relay's image, both platforms in one build: its builder isn't a
# cross build, so arm64 compiles under QEMU here, slowly; the relay is
# small. It runs after the server's arm64 build, not beside it. Before any
# tag moves, the amd64 image must say it is this version and hold no
# server binary (RL-T24).
publish-relay:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-arm64]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
RELAY_IMAGE: ${{ vars.REGISTRY }}/${{ github.repository_owner }}/inbuxa-relay
VERSION: ${{ needs.version.outputs.version }}
CARGO_VERSION: ${{ needs.version.outputs.cargo }}
TAGS: ${{ needs.version.outputs.tags }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
- run: |
set -eu
for arch in amd64 arm64; do
docker buildx build -f Dockerfile.relay \
--platform "linux/$arch" \
--provenance=false --sbom=false \
--tag "$RELAY_IMAGE:$VERSION-$arch" \
--push .
done
said="$(docker run --rm --platform linux/amd64 "$RELAY_IMAGE:$VERSION-amd64" --version)"
[ "$said" = "inbuxa-relay $CARGO_VERSION" ] || { echo "the relay image says '$said'" >&2; exit 1; }
id="$(docker create --platform linux/amd64 "$RELAY_IMAGE:$VERSION-amd64")"
if docker export "$id" | tar -t | grep -qE '(^|/)inbuxa$'; then
docker rm -f "$id" >/dev/null
echo "the relay image holds a server binary" >&2; exit 1
fi
docker rm -f "$id" >/dev/null
args=""
for t in $TAGS; do args="$args --tag $RELAY_IMAGE:$t"; done
docker buildx imagetools create $args \
"$RELAY_IMAGE:$VERSION-amd64" "$RELAY_IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$RELAY_IMAGE:$VERSION"
- run: |
apk add --no-cache -q curl
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/inbuxa-relay/-/link/${GITHUB_REPOSITORY#*/}" \
|| echo "package already linked (or link refused); not fatal"
- if: always()
run: docker logout "$REGISTRY" || true
# BUILD_ON=github: waits for the GitHub mirror's run for this tag, which
# posts its result back as the commit status "github/ci (tag)", and takes
# its answer. Fails after the timeout if no answer comes.
@@ -241,6 +309,7 @@ jobs:
except urllib.error.HTTPError as e:
if e.code != 404: raise
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
relay = f"{os.environ['REGISTRY']}/{os.environ['REPO'].split('/')[0]}/inbuxa-relay:{version}"
also = " ".join(f"`:{t}`" for t in os.environ["TAGS"].split()[1:])
pre = os.environ["PRERELEASE"] == "true"
body = (("Release candidate for the next series, to try before it ships. "
@@ -252,7 +321,12 @@ jobs:
"`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this "
"release's image for that architecture, so it is the same build. The image "
"grants it `cap_net_bind_service`; a host install has to grant that itself "
"(`setcap`, or `AmbientCapabilities` in the unit) to bind port 25.")
"(`setcap`, or `AmbientCapabilities` in the unit) to bind port 25.\n\n"
f"inbuxa Relay, the edge relay, ships from the same tag: image `{relay}` "
"(linux/amd64, linux/arm64), and static binaries `inbuxa-relay-x86_64-linux-musl` "
"and `inbuxa-relay-aarch64-linux-musl`, each with a `.sig` file by the inbuxa "
"release key. inbuxa-admin's Relays page takes a binary and its `.sig` to "
"upgrade a relay.")
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body,
"prerelease": pre}).encode()
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
@@ -272,7 +346,7 @@ jobs:
# amd64 runner and copying a file out of it needs no emulation.
binaries:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-arm64, release]
needs: [version, publish-arm64, publish-relay, release]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
@@ -301,7 +375,17 @@ jobs:
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
rm inbuxa
done
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
# The relay's, as bare binaries: an upgrade (RL-29) takes the file itself
relay="$REGISTRY/${REPO%%/*}/inbuxa-relay:$VERSION"
for pair in amd64:x86_64 arm64:aarch64; do
arch="${pair%%:*}"; name="inbuxa-relay-${pair#*:}-linux-musl"
docker pull -q --platform "linux/$arch" "$relay"
id="$(docker create --platform "linux/$arch" "$relay")"
docker cp "$id:/usr/local/bin/inbuxa-relay" "$name"
docker rm -f "$id" >/dev/null
chmod 0755 "$name"
done
sha256sum inbuxa-linux-*.tar.gz inbuxa-relay-* > SHA256SUMS
cat SHA256SUMS
- name: attach them to the release
run: |
@@ -319,7 +403,9 @@ jobs:
rel = get(f"/releases/tags/{tag}")
assets = {a["name"]: a["id"] for a in get(f"/releases/{rel['id']}/assets")}
for path in ["/out/inbuxa-linux-amd64.tar.gz", "/out/inbuxa-linux-arm64.tar.gz", "/out/SHA256SUMS"]:
for path in ["/out/inbuxa-linux-amd64.tar.gz", "/out/inbuxa-linux-arm64.tar.gz",
"/out/inbuxa-relay-x86_64-linux-musl", "/out/inbuxa-relay-aarch64-linux-musl",
"/out/SHA256SUMS"]:
name = os.path.basename(path)
# A re-run of a tag replaces its assets rather than leaving two
# files with the same name and different contents.
@@ -342,6 +428,55 @@ jobs:
- if: always()
run: docker logout "$REGISTRY" || true
# Signs the relay binaries on the release with the inbuxa release key
# (RL-29), whichever forge put them there: this forge's binaries job, or
# the GitHub run the `github` job waited for. Each .sig names the Cargo
# version the binary's --version prints and the target in its file name.
# The key is written to a file only for the signing, and removed.
relay-sign:
needs: [binaries, github]
if: ${{ always() && (needs.binaries.result == 'success' || needs.github.result == 'success') }}
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELAY_RELEASE_KEY: ${{ secrets.RELAY_RELEASE_KEY }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- shell: sh
run: |
set -eu
if [ -z "$RELAY_RELEASE_KEY" ]; then
echo "::warning::RELAY_RELEASE_KEY is not set: the relay binaries stay unsigned, and no relay takes them as an upgrade"
exit 0
fi
apk add --no-cache -q bash coreutils curl jq openssl python3
version="$(python3 tools/release/version.py --cargo)"
api="$CI_SERVER_INTERNAL/api/v1/repos/$REPO"
auth="Authorization: token $TOKEN"
rel="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
assets="$(curl -fsS -H "$auth" "$api/releases/$rel/assets")"
umask 077
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$RELAY_RELEASE_KEY" > "$key"
for target in x86_64-linux-musl aarch64-linux-musl; do
name="inbuxa-relay-$target"
id="$(echo "$assets" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id' | head -n1)"
[ -n "$id" ] || { echo "the release has no $name" >&2; exit 1; }
curl -fsSL -H "$auth" -o "$name" "$CI_SERVER_INTERNAL/$REPO/releases/download/$TAG/$name"
bash tools/release/sign-relay.sh "$key" "$name" "$version" "$target"
for old in $(echo "$assets" | jq -r --arg n "$name.sig" '.[] | select(.name == $n) | .id'); do
curl -fsS -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$old"
done
curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$name.sig" "$api/releases/$rel/assets?name=$name.sig"
echo "signed $name ($version, $target)"
cat "$name.sig"
done
# The release above is made with the job's own token, and Gitea starts no
# workflow for events the Actions bot causes -- announce.yml's
# 'on: release' never fires for it -- so announce it from here.
+102 -5
View File
@@ -184,6 +184,8 @@ jobs:
# candidate gets 27.1-rc.N and 27.1-rc; a release 27.1.N, 27.1 and latest.
tags: ${{ steps.v.outputs.tags }}
prerelease: ${{ steps.v.outputs.prerelease }}
# The Cargo form (27.1.0-rc.N), which `inbuxa-relay --version` prints.
cargo: ${{ steps.v.outputs.cargo }}
steps:
# Full history, and every branch as origin/*: the ancestry check cannot
# be answered from a shallow clone.
@@ -208,6 +210,7 @@ jobs:
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "tags=${tags% }" >> "$GITHUB_OUTPUT"
echo "prerelease=$pre" >> "$GITHUB_OUTPUT"
echo "cargo=$(python3 tools/release/version.py --cargo)" >> "$GITHUB_OUTPUT"
echo "image tags: ${tags% }; prerelease: $pre"
# Each architecture on its own native runner, side by side. The Dockerfile
@@ -305,6 +308,83 @@ jobs:
"$GITEA_URL/api/v1/packages/${owner,,}/container/$name/-/link/$name" \
|| echo "package already linked (or link refused); not fatal"
# inbuxa Relay's image (relay spec, RL-31), from the same tag: its own
# Dockerfile, a static musl build on each architecture's own runner, under
# the server's version tags. It is small, so it needs none of the room the
# server's build does.
relay-publish:
needs: [version]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- run: echo "RELAY_IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY_OWNER,,}/inbuxa-relay" >> "$GITHUB_ENV"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: Dockerfile.relay
platforms: linux/${{ matrix.arch }}
provenance: false
sbom: false
push: true
tags: ${{ env.RELAY_IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }}
# The relay's index, under the server's tags. Before it moves any tag, the
# amd64 image must say it is this version, and must hold no server binary
# (RL-T24).
relay-index:
needs: [version, relay-publish]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
CARGO_VERSION: ${{ needs.version.outputs.cargo }}
TAGS: ${{ needs.version.outputs.tags }}
steps:
- run: echo "RELAY_IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY_OWNER,,}/inbuxa-relay" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- run: |
set -euo pipefail
said="$(docker run --rm "$RELAY_IMAGE:$VERSION-amd64" --version)"
[ "$said" = "inbuxa-relay $CARGO_VERSION" ] || { echo "the relay image says '$said'" >&2; exit 1; }
id="$(docker create "$RELAY_IMAGE:$VERSION-amd64")"
files="$(docker export "$id" | tar -t)"
docker rm -f "$id" >/dev/null
if grep -qE '(^|/)inbuxa$' <<<"$files"; then
echo "the relay image holds a server binary" >&2; exit 1
fi
args=()
for t in $TAGS; do args+=(--tag "$RELAY_IMAGE:$t"); done
docker buildx imagetools create "${args[@]}" \
"$RELAY_IMAGE:$VERSION-amd64" "$RELAY_IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$RELAY_IMAGE:$VERSION"
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
owner="${GITHUB_REPOSITORY%%/*}"; name="${GITHUB_REPOSITORY#*/}"
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/packages/${owner,,}/container/inbuxa-relay/-/link/$name" \
|| echo "package already linked (or link refused); not fatal"
# The weekly release creates its Release (and so the tag) on Gitea first; a
# tag pushed by hand has none. Either way the tag ends up with exactly one
# Release there, created once the image exists so its pull instructions
@@ -327,10 +407,13 @@ jobs:
if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi
[ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; }
image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION"
relay="$REGISTRY/${GITHUB_REPOSITORY_OWNER,,}/inbuxa-relay:$VERSION"
also="$(for t in ${TAGS#"$VERSION"}; do printf ' `:%s`' "$t"; done)"
body="Container image: \`$image\` (linux/amd64, linux/arm64); also$also.
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25."
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25.
inbuxa Relay, the edge relay, ships from the same tag: image \`$relay\` (linux/amd64, linux/arm64), and static binaries \`inbuxa-relay-x86_64-linux-musl\` and \`inbuxa-relay-aarch64-linux-musl\`, each with a \`.sig\` file by the inbuxa release key. inbuxa-admin's Relays page takes a binary and its \`.sig\` to upgrade a relay."
if [ "$PRERELEASE" = true ]; then
body="Release candidate for the next series, to try before it ships. \`:latest\` stays on the current release.
@@ -347,7 +430,7 @@ jobs:
# the image runs. `docker create` starts nothing, so copying a file out of
# the arm64 image on an amd64 runner needs no emulation.
binaries:
needs: [version, index, release]
needs: [version, index, relay-index, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
@@ -373,8 +456,21 @@ jobs:
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
rm inbuxa
done
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
# The relay's, as bare binaries: an upgrade (RL-29) takes the file itself
relay="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY_OWNER,,}/inbuxa-relay:$VERSION"
for pair in amd64:x86_64 arm64:aarch64; do
arch="${pair%%:*}"; name="inbuxa-relay-${pair#*:}-linux-musl"
docker pull -q --platform "linux/$arch" "$relay"
id="$(docker create --platform "linux/$arch" "$relay")"
docker cp "$id:/usr/local/bin/inbuxa-relay" "$name"
docker rm -f "$id" >/dev/null
chmod 0755 "$name"
done
sha256sum inbuxa-linux-*.tar.gz inbuxa-relay-* > SHA256SUMS
cat SHA256SUMS
# The relay binaries' .sig files are made on Gitea, which holds the
# release key (publish.yml, relay-sign), once these are attached.
#
# A re-run of a tag replaces its assets rather than leaving two files
# with the same name and different contents.
#
@@ -389,7 +485,8 @@ jobs:
retry=(--retry 5 --retry-all-errors --retry-delay 15)
rel="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
assets="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/$rel/assets")"
for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz out/SHA256SUMS; do
for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz \
out/inbuxa-relay-x86_64-linux-musl out/inbuxa-relay-aarch64-linux-musl out/SHA256SUMS; do
name="$(basename "$f")"
old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")"
for id in $old; do curl -fsS "${retry[@]}" -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
@@ -486,7 +583,7 @@ jobs:
# way round) count as passing; a failed or cancelled one does not.
report:
if: ${{ always() && vars.BUILD_ON == 'github' }}
needs: [start, fork-checks, build, version, publish, index, release, binaries]
needs: [start, fork-checks, build, version, publish, index, relay-publish, relay-index, release, binaries]
runs-on: ubuntu-latest
steps:
- env:
+40
View File
@@ -0,0 +1,40 @@
# inbuxa Relay's image (relay spec, RL-31): the inbuxa-relay binary and
# nothing of the server. It is a static musl build, built natively for the
# platform asked for (each architecture has its own runner), so the binary
# taken out of this image for a Release runs on any Linux of that
# architecture.
#
# docker buildx build -f Dockerfile.relay --platform linux/amd64 .
#
# The image runs as an unprivileged user (2001), may bind ports 25 and 443 and
# nothing more, and keeps its state in /var/lib/inbuxa-relay. It sets
# INBUXA_RELAY_CONTAINER, so the relay reports selfUpgrade: false and an
# upgrade is a new image (RL-29). On its first run it joins with
# INBUXA_RELAY_JOIN_TOKEN.
#
# Docker Hub images, pulled through Google's mirror, as in Dockerfile.
FROM mirror.gcr.io/library/rust:1-alpine3.22 AS builder
RUN apk add --no-cache musl-dev build-base cmake perl libcap ca-certificates
WORKDIR /build
COPY . .
RUN cargo build --release --locked -p inbuxa-relay && \
install -m 0755 target/release/inbuxa-relay /inbuxa-relay && \
# Static, or it won't run on a host without musl
! ldd /inbuxa-relay 2>/dev/null | grep -q '=>' && \
# Set here: the copy below keeps it
setcap 'cap_net_bind_service=+ep' /inbuxa-relay && \
mkdir -m 0700 /state
# The binary and the CA roots, nothing else (RL-30): no shell, no package
# manager. The user is a number, as there is no /etc/passwd.
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /inbuxa-relay /usr/local/bin/inbuxa-relay
COPY --from=builder --chown=2001:2001 /state /var/lib/inbuxa-relay
USER 2001:2001
WORKDIR /var/lib/inbuxa-relay
VOLUME ["/var/lib/inbuxa-relay"]
ENV INBUXA_RELAY_CONTAINER=1
EXPOSE 25 443
ENTRYPOINT ["/usr/local/bin/inbuxa-relay"]
CMD ["run", "--state", "/var/lib/inbuxa-relay"]
+47 -3
View File
@@ -26,9 +26,26 @@ use aws_lc_rs::{
};
use std::{cmp::Ordering, fmt};
/// The inbuxa release keys' public halves (raw Ed25519). A build without
/// one refuses every upgrade.
pub const RELEASE_KEYS: &[[u8; 32]] = &[];
/// The inbuxa release keys' public halves (raw Ed25519), as
/// `tools/release/new-relay-keys.sh` printed them on 2026-10-30. A build
/// without one refuses every upgrade.
pub const RELEASE_KEYS: &[[u8; 32]] = &[
// ci: the RELAY_RELEASE_KEY secret, which signs every release
// J0kY_vzXTC8T8gxErBMYI6cVk4Bwki65HlJGoDp15uw
[
0x27, 0x49, 0x18, 0xfe, 0xfc, 0xd7, 0x4c, 0x2f, 0x13, 0xf2, 0x0c, 0x44, 0xac, 0x13, 0x18,
0x23, 0xa7, 0x15, 0x93, 0x80, 0x70, 0x92, 0x2e, 0xb9, 0x1e, 0x52, 0x46, 0xa0, 0x3a, 0x75,
0xe6, 0xec,
],
// offline: kept off any server, to sign by hand when the CI key has to
// be replaced
// vGsTE95CJ3vbGKVtfQctFEp0fOZ4wQ9QWTOFe0KF7_8
[
0xbc, 0x6b, 0x13, 0x13, 0xde, 0x42, 0x27, 0x7b, 0xdb, 0x18, 0xa5, 0x6d, 0x7d, 0x07, 0x2d,
0x14, 0x4a, 0x74, 0x7c, 0xe6, 0x78, 0xc1, 0x0f, 0x50, 0x59, 0x33, 0x85, 0x7b, 0x42, 0x85,
0xef, 0xff,
],
];
const HEADER: &str = "inbuxa-relay release 1";
@@ -326,6 +343,33 @@ mod tests {
const OPENSSL_SIGNATURE: &str =
"zRj90ByVN18fmW-GOZ9cvGBK1ZsYYoNvowGsbSBYcslJhgIgmKrYHgRgq1CSIy4G-EzR5uexCzAUoa3FEcluAA";
/// The keys compiled in are the ones the comments name.
#[test]
fn release_keys_as_printed() {
let printed: Vec<String> = RELEASE_KEYS.iter().map(|key| b64::encode(key)).collect();
assert_eq!(
printed,
[
"J0kY_vzXTC8T8gxErBMYI6cVk4Bwki65HlJGoDp15uw",
"vGsTE95CJ3vbGKVtfQctFEp0fOZ4wQ9QWTOFe0KF7_8"
]
);
for key in RELEASE_KEYS {
// Each is a key a signature can be checked against
assert_eq!(
verify_signature(
&format!(
"{HEADER}\nversion 1.0.0\ntarget t\nsha256 {}\nsignature {}\n",
"0".repeat(64),
"A".repeat(86)
),
&[*key]
),
Err(ReleaseError::BadSignature)
);
}
}
#[test]
fn versions_in_order() {
let less = |a: &str, b: &str| compare_versions(a, b) == Some(Ordering::Less);
+34 -3
View File
@@ -651,7 +651,7 @@ A **Relays** page under Settings › Mail flow:
| Static musl binary, stripped, x86_64 and aarch64 | ≤ 15 MB |
| Resident memory, joined and idle | ≤ 32 MB |
| Resident memory, 50 concurrent SMTP sessions | ≤ 128 MB |
| Container image | `FROM scratch`: the binary and CA roots only |
| Container image | `FROM scratch`: the binary and CA roots only (12.4 MB on amd64, 2026-10-30) |
- **RL-31.** It ships as its own artifacts, from the same release tag as
the server: a multi-arch image (linux/amd64, linux/arm64) named
@@ -952,8 +952,39 @@ In progress. Done so far:
output checks out against the Rust code. On this server, an upgrade
with anything else, an unknown blob, or a signature by no release key
is refused before any relay sees it.
- Not yet: the release key itself, and signing in the release workflow,
come with packaging (step 6). Until then no build accepts an upgrade.
- The release keys are made with `tools/release/new-relay-keys.sh`: a CI
key, kept as the Gitea Actions secret `RELAY_RELEASE_KEY`, and an
offline key, kept off any server, for signing by hand when the CI key
has to be replaced. Both public halves are in `RELEASE_KEYS` (made
2026-10-30), so every build from then on takes signed upgrades.
Packaging (step 6, RL-31):
- `Dockerfile.relay` builds the relay alone on Alpine, as a static musl
binary, natively on each architecture's runner. The image is `FROM
scratch`: the binary, the CA roots and the state directory, owned by
uid 2001; about 12 MB. It runs as 2001, with `cap_net_bind_service` on
the binary and nothing else, sets `INBUXA_RELAY_CONTAINER` (so
`selfUpgrade: false`), and joins from `INBUXA_RELAY_JOIN_TOKEN` on its
first run.
- A release tag builds it beside the server, on whichever forge builds:
`<registry>/inbuxa/inbuxa-relay` under the server's tags. Before a tag
moves, the amd64 image must print `inbuxa-relay <Cargo version>` and
hold no `inbuxa` binary (RL-T24). The Release gets the binaries taken
out of the image, `inbuxa-relay-x86_64-linux-musl` and
`inbuxa-relay-aarch64-linux-musl`, in `SHA256SUMS` with the server's.
- Gitea's `relay-sign` job then signs both with `RELAY_RELEASE_KEY`, and
attaches the `.sig` files, whichever forge built them. Without the
secret it warns and signs nothing. With GitHub building, a binary is
only as trustworthy as that build: GitHub uploads it, Gitea signs what
it finds.
- `resources/systemd/inbuxa-relay.service` runs a host install from
`/opt/inbuxa-relay/bin`, owned by the `inbuxa-relay` user so an upgrade
can replace the binary, with `Restart=always`, the one capability, and
the filesystem read-only but for that directory and the state
directory.
- Still to come in step 6: the installer path and the docs page (with
the WireGuard setup, RL-32).
Order:
+44
View File
@@ -0,0 +1,44 @@
# inbuxa Relay on a host (relay spec, RL-31). The binary lives in
# /opt/inbuxa-relay/bin, owned by the inbuxa-relay user, so an upgrade from
# inbuxa-admin can replace it (RL-29); the relay then exits, and Restart=
# starts the new one. It may bind ports 25 and 443 and nothing more.
[Unit]
Description=inbuxa Relay
Conflicts=postfix.service sendmail.service exim4.service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/inbuxa-relay/bin/inbuxa-relay run --state /var/lib/inbuxa-relay
Restart=always
RestartSec=2
SyslogIdentifier=inbuxa-relay
User=inbuxa-relay
Group=inbuxa-relay
StateDirectory=inbuxa-relay
StateDirectoryMode=0700
LimitNOFILE=65536
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/opt/inbuxa-relay/bin
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
# SPDX-License-Identifier: AGPL-3.0-only
#
# Make the inbuxa relay release keys (relay spec, RL-29):
#
# tools/release/new-relay-keys.sh DIR
#
# Writes two Ed25519 keys into DIR, which must not exist yet and is made
# readable by you alone:
#
# ci.pem goes into the inbuxa-server Actions secret
# RELAY_RELEASE_KEY; publish.yml signs every relay binary
# with it. Delete this copy once the secret is set.
# offline.pem stays offline (a password manager, or a USB key in a
# drawer). It signs a release by hand with sign-relay.sh
# when the CI key has to be replaced.
#
# Prints only the two public halves, for RELEASE_KEYS in
# crates/relay-proto/src/release.rs. Neither private key is printed.
set -euo pipefail
if [ $# -ne 1 ]; then
sed -n '5,20p' "$0" | sed 's/^# \{0,1\}//'
exit 2
fi
dir=$1
if [ -e "$dir" ]; then
echo "$dir exists: choose a new directory" >&2
exit 1
fi
umask 077
mkdir -p "$dir"
here=$(dirname "$0")
for name in ci offline; do
openssl genpkey -algorithm ed25519 -out "$dir/$name.pem"
printf '%-8s %s\n' "$name" "$("$here/sign-relay.sh" --public "$dir/$name.pem")"
done
echo
echo "Set the secret from $dir/ci.pem, move $dir/offline.pem offline, then delete $dir."