diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 6e95c1fb..dde2209e 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b8d42a3..3f4db6bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/Dockerfile.relay b/Dockerfile.relay new file mode 100644 index 00000000..01660186 --- /dev/null +++ b/Dockerfile.relay @@ -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"] diff --git a/crates/relay-proto/src/release.rs b/crates/relay-proto/src/release.rs index 86f3cf08..0548937a 100644 --- a/crates/relay-proto/src/release.rs +++ b/crates/relay-proto/src/release.rs @@ -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 = 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); diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index f4e173d7..2e09142a 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -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: + `/inbuxa/inbuxa-relay` under the server's tags. Before a tag + moves, the amd64 image must print `inbuxa-relay ` 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: diff --git a/resources/systemd/inbuxa-relay.service b/resources/systemd/inbuxa-relay.service new file mode 100644 index 00000000..0d80c0e9 --- /dev/null +++ b/resources/systemd/inbuxa-relay.service @@ -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 diff --git a/tools/release/new-relay-keys.sh b/tools/release/new-relay-keys.sh new file mode 100755 index 00000000..a2694a33 --- /dev/null +++ b/tools/release/new-relay-keys.sh @@ -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."