diff --git a/.gitea/workflows/build-rpi.yml b/.gitea/workflows/build-rpi.yml new file mode 100644 index 0000000..928d767 --- /dev/null +++ b/.gitea/workflows/build-rpi.yml @@ -0,0 +1,116 @@ +# One build workflow per hardware family, identical on every branch — do not +# add per-branch copies. build-x86.yaml is the same file with a different matrix. +# +# The branch/ref selects the channel, nothing else does: the flake's `channel` +# block picks the channel manifest whose closure is baked as the app, the Attic +# repo, and the upgrade ref, so the same config names build dev images on `dev` +# and prod images on `main`. +# +# Trigger model: +# * push to `dev` -> build every development image +# * new git tag -> build every production image (tags live on main) +# * manual dispatch -> build the channel of the selected branch +# +# This file builds the Raspberry Pi variant: +# Raspberry Pi -> .config.system.build.sdImage +# `orion-lxc` emulates aarch64-linux, so this runs on the same runner. +name: Build Kiosk Image (Raspberry Pi) + +on: + workflow_dispatch: + push: + branches: + - dev + tags: + - "*" + +env: + # Both channel caches plus the Raspberry Pi cachix, so a build can substitute + # from whichever channel the checked-out ref selects. tarball-ttl=0 keeps the + # eval-time manifest fetch (builtins.fetchurl in flake.nix) from reusing a + # stale copy cached by an earlier build on this persistent runner. + NIX_CONFIG: | + extra-substituters = https://attic.radiantwavetech.com/radiantwave https://attic.radiantwavetech.com/radiantwave-dev https://nixos-raspberrypi.cachix.org + extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= + tarball-ttl = 0 + +jobs: + build: + name: ${{ matrix.label }} + runs-on: orion-lxc + strategy: + fail-fast: false + matrix: + include: + - label: rpi + system: radiantwavekiosk-rpi + target: sdImage + outdir: sd-image + ext: img.zst + arch: aarch64-linux + manifest: aarch64-manifest.json + + steps: + - name: Clone repository + run: | + rm -rf ./* ./.[!.]* 2>/dev/null || true + ref="${GITHUB_REF#refs/heads/}" + ref="${ref#refs/tags/}" + git clone --depth 1 --branch "$ref" \ + ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . + git log --oneline -1 + + - name: Determine channel + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + CHANNEL=$(nix eval --impure --raw .#channelName) + if [ "$CHANNEL" = "radiantwave" ]; then LABEL=prod; else LABEL=dev; fi + echo "CHANNEL=$CHANNEL" >> "$GITHUB_ENV" + echo "CHANNEL_LABEL=$LABEL" >> "$GITHUB_ENV" + echo "Building the '$CHANNEL' channel ($LABEL)" + + # Fail fast if the closure the channel manifest announces is not in the + # channel's Attic cache. We bake that exact storePath, so without it the + # image would try to compile RadiantWave from source (which never works: + # this repo has no app source). Runs for both channels; RadiantWave CI + # publishes the manifest only after the matching Attic push succeeds. + - name: Verify published app closure is in the channel cache + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/${{ matrix.manifest }}" + MANIFEST=$(curl -fsSL "$MANIFEST_URL") + PUB_PATH=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).storePath') + + echo "channel : $CHANNEL" + echo "attic : $ATTIC" + echo "manifest: $MANIFEST_URL" + echo "closure : $PUB_PATH" + + HASH=$(basename "$PUB_PATH" | cut -d- -f1) + if ! curl -fsS -o /dev/null "$ATTIC/${HASH}.narinfo"; then + echo "ERROR: $PUB_PATH is not in $ATTIC; the image would compile RadiantWave from source." + exit 1 + fi + + - name: Prepare output directory + run: mkdir -p /var/lib/gitea-runner/radiantwave + + - name: Build ${{ matrix.label }} + run: | + export NIXPKGS_ALLOW_UNFREE=1 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + nix build -L --impure \ + ".#nixosConfigurations.${{ matrix.system }}.config.system.build.${{ matrix.target }}" + + - name: Publish ${{ matrix.label }} + run: | + ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) + FILENAME="radiantwave-${{ matrix.label }}-${CHANNEL_LABEL}-${ISO_DATE}.${{ matrix.ext }}" + cp -L result/${{ matrix.outdir }}/*.${{ matrix.ext }} \ + "/var/lib/gitea-runner/radiantwave/${FILENAME}" + scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ + 157.245.124.103:/mnt/infrastructure_data/files/images/ + rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ No newline at end of file diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index aad8613..7864ea6 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,20 +1,19 @@ -# One build workflow for every branch — do not add per-branch copies. +# One build workflow per hardware family, identical on every branch — do not +# add per-branch copies. build-rpi.yaml is the same file with a different matrix. # # The branch/ref selects the channel, nothing else does: the flake's `channel` # block picks the channel manifest whose closure is baked as the app, the Attic # repo, and the upgrade ref, so the same config names build dev images on `dev` -# and prod images on `main`. This file itself is identical on both branches. +# and prod images on `main`. # # Trigger model: # * push to `dev` -> build every development image # * new git tag -> build every production image (tags live on main) # * manual dispatch -> build the channel of the selected branch # -# Hardware variants come from an include-matrix: +# This file builds the x86_64 ISO variants: # ISO variants -> .config.system.build.isoImage (storeContents pulls in the closure) -# Raspberry Pi -> .config.system.build.sdImage -# `orion-lxc` emulates aarch64-linux, so the Pi build runs alongside the rest. -name: Build Kiosk Images +name: Build Kiosk Images (x86) on: workflow_dispatch: @@ -42,8 +41,11 @@ jobs: fail-fast: false matrix: include: - - label: kiosk - system: kiosk-iso + # NOTE: Gitea schedules matrix jobs in reverse definition order on + # the single orion-lxc runner, so the list is reversed on purpose: + # android -> commercial -> kiosk means kiosk builds FIRST. + - label: android + system: android-iso target: isoImage outdir: iso ext: iso @@ -56,20 +58,13 @@ jobs: ext: iso arch: x86_64-linux manifest: manifest.json - - label: android - system: android-iso + - label: kiosk + system: kiosk-iso target: isoImage outdir: iso ext: iso arch: x86_64-linux manifest: manifest.json - - label: rpi - system: radiantwavekiosk-rpi - target: sdImage - outdir: sd-image - ext: img.zst - arch: aarch64-linux - manifest: aarch64-manifest.json steps: - name: Clone repository @@ -134,4 +129,4 @@ jobs: "/var/lib/gitea-runner/radiantwave/${FILENAME}" scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ 157.245.124.103:/mnt/infrastructure_data/files/images/ - rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" + rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ No newline at end of file diff --git a/configuration.nix b/configuration.nix index 95a2073..9f90368 100644 --- a/configuration.nix +++ b/configuration.nix @@ -14,6 +14,18 @@ let then "/var/lib/radiantwave/current/bin/radiantwave" else "/run/current-system/sw/bin/radiantwave"; myKey = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4qWNZnYU5JaqR9c2qh6oFZktzVNBOx42PfDaGofCL6 cnclack@pm.me macbook" ]; + + # Headscale preauth key. Paste/replace here. + # + # This MUST be a *reusable* key with a long expiry (1 year is good), created + # with: + # headscale preauthkeys create --user --reusable --expiration 8760h + # + # A single-use key only ever enrolls one node, which would mean a fresh key + # plus a rebuild for every kiosk that joins the fleet. A reusable key lets any + # number of kiosks enroll unattended, and the timer below retries the join so + # a node that misses its window at boot heals on its own. + headscaleAuthKey = "hskey-auth-UfU_eS_sdOu6-CjrqrVdDJFRFsPnaTtK5sFuCiVlffJAS7uQ1wX1_65MNk205WGsF4clOiBD3ZOV-"; in { ############################################################ @@ -88,6 +100,127 @@ in networking.networkmanager.enable = true; + ############################################################ + # Tailscale (self-hosted Headscale control plane) + ############################################################ + # The node joins the RadiantWave Headscale server at + # hs.radiantwavetech.com using the *hardware serial* as its Tailscale + # node name (not networking.hostName), so a kiosk is always reachable at + # even though every variant shares one hostName per branch. + # + # The preauth key is set in `headscaleAuthKey` in the let block at the + # top of this file. NOTE: it ends up in the world-readable /nix/store, so + # keep this file out of public repos while a live key is in it. + services.tailscale = { + enable = true; + useRoutingFeatures = "client"; + }; + + # Reach the kiosk's services (SSH, etc.) over the tailnet without extra + # per-port rules. Membership is gated by the preauth key. + networking.firewall.trustedInterfaces = [ "tailscale0" ]; + + systemd.services.radiantwave-tailscale-auth = { + description = "Join the RadiantWave tailnet using the hardware serial as node name"; + after = [ "tailscaled.service" "network-online.target" ]; + wants = [ "tailscaled.service" "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + # Deliberately no RemainAfterExit: the unit has to fall back to inactive + # so the timer below can trigger it again. The script is idempotent, so + # re-running it on an already-joined kiosk is a cheap no-op. + }; + path = [ pkgs.tailscale pkgs.coreutils pkgs.gnused pkgs.gnugrep ]; + script = '' + set -eu + + # Hardware serial. x86 exposes it via DMI; Raspberry Pi exposes it in + # the device tree (null-terminated), mirrored under /proc/device-tree. + serial="" + for f in \ + /sys/class/dmi/id/product_serial \ + /sys/class/dmi/id/board_serial \ + /sys/firmware/devicetree/base/serial-number \ + /proc/device-tree/serial-number + do + if [ -r "$f" ]; then + serial="$(tr -d '\000' < "$f" | tr -d '\n\r')" + [ -n "$serial" ] && break + fi + done + + # Tailscale node names are RFC 1035 labels: lowercase, [a-z0-9-], <=63. + serial="$(printf '%s' "$serial" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' \ + | sed -e 's/-\{1,\}/-/g' -e 's/^-//' -e 's/-$//' \ + | cut -c1-63)" + [ -n "$serial" ] || serial="radiantwave-unknown" + + # Already joined? Bail out early. This is what makes the periodic + # re-invocation from the timer below cheap and safe to run forever. + # The JSON is pretty-printed with a space, so allow optional whitespace. + state="$(tailscale status --json 2>/dev/null \ + | sed -n 's/.*"BackendState": *"\([A-Za-z]*\)".*/\1/p')" + if [ "$state" = "Running" ]; then + echo "radiantwave-tailscale-auth: already joined as '$serial'" + exit 0 + fi + + echo "radiantwave-tailscale-auth: joining as '$serial' (state: ''${state:-unknown})" + for _ in $(seq 1 30); do + if tailscale up \ + --auth-key=${pkgs.lib.escapeShellArg headscaleAuthKey} \ + --login-server=https://hs.radiantwavetech.com \ + --hostname="$serial" \ + --accept-dns=false + then + echo "radiantwave-tailscale-auth: connected" + exit 0 + fi + sleep 2 + done + + # Best-effort by design: do NOT exit non-zero here. + # + # This unit is part of the new generation that switch-to-configuration + # starts, and *any* failing unit among those aborts the switch with + # status 4/NOPERMISSION. While the preauth key was expired that is exactly + # what happened: this unit failed, which failed `switch-to-configuration`, + # which failed nixos-upgrade.service -- so the tailscale key silently + # stopped *all* system upgrades on the fleet every 10 minutes. + # + # The retry timer below is what actually recovers the join, so a failed + # attempt here must never take down the system switch with it. + echo "radiantwave-tailscale-auth: could not join tailnet after retries;" >&2 + echo "radiantwave-tailscale-auth: leaving the join to the retry timer" >&2 + exit 0 + ''; + }; + + # The join only needs to happen once per node, but it fails for reasons + # outside our control: the network may not be usable yet at boot, tailscaled + # may be restarted later, or the preauth key may be rotated out from under + # the fleet. A timer re-runs the (idempotent) join so a kiosk recovers on its + # own instead of needing a manual `systemctl restart`. + # + # This replaces the old behavior where the one-shot unit failed once at boot + # and stayed failed forever (Restart=no, NRestarts=0). + systemd.timers.radiantwave-tailscale-auth = { + description = "Periodically ensure the kiosk is joined to the tailnet"; + wantedBy = [ "timers.target" ]; + timerConfig = { + # The join is attempted at boot via multi-user.target above; this is the + # first retry in case that one lost the race with the network. + OnBootSec = "5min"; + # Measured from when the unit last went inactive, so a slow join doesn't + # immediately retrigger. + OnUnitInactiveSec = "10min"; + Unit = "radiantwave-tailscale-auth.service"; + }; + }; + system.autoUpgrade = { enable = true; flake = upgradeFlakeRef; @@ -273,4 +406,4 @@ in echo "Backup completed at $(date)" > "$BACKUP_DIR/.backup-timestamp" ''; }; -} +} \ No newline at end of file diff --git a/radiantwave-updater.nix b/radiantwave-updater.nix index 9d05be7..2ced142 100644 --- a/radiantwave-updater.nix +++ b/radiantwave-updater.nix @@ -80,6 +80,7 @@ in ${pkgs.coreutils}/bin/ln -sfn "$NEW_PATH" /var/lib/radiantwave/current.tmp ${pkgs.coreutils}/bin/mv -T /var/lib/radiantwave/current.tmp /var/lib/radiantwave/current echo "radiantwave-updater: swapped ''${CURRENT} -> ''${NEW_PATH}" + loginctl terminate-user kiosk ''; };