feat!: make declarative version

This commit is contained in:
Jet 2026-03-21 02:09:38 -07:00
parent cf3c5ef1f5
commit f4d95c595e
No known key found for this signature in database
13 changed files with 493 additions and 95 deletions

1
.envrc
View file

@ -1 +1,2 @@
export NIX_CONFIG="eval-cache = false"
use flake

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ target/
result
result-*
.direnv
pi-serial-*.log
serial-*.log

View file

@ -16,5 +16,8 @@ Pi (door sensor) ──webhook──> Cache ──webhook──> Discord
|-----------|------------|
| [`pi/`](pi/) | NixOS config + Rust service for the Pi |
| [`remote/`](remote/) | Server-side services (cache and Discord bot) |
| [`secrets/`](secrets/) | Shared agenix-encrypted secrets and recipient rules |
Each directory has its own README with setup and configuration details.
For hosted deployment, another repo such as `../extremist-software` imports `noisebell.nixosModules.default`. That host repo provides deployment-specific values like domains, ports, and the Pi address, while the Noisebell module itself points `agenix` at the encrypted files in `secrets/` and consumes the decrypted runtime files on the target machine.

111
flake.lock generated
View file

@ -23,6 +23,22 @@
"type": "github"
}
},
"argononed": {
"flake": false,
"locked": {
"lastModified": 1729566243,
"narHash": "sha256-DPNI0Dpk5aym3Baf5UbEe5GENDrSmmXVdriRSWE+rgk=",
"owner": "nvmd",
"repo": "argononed",
"rev": "16dbee54d49b66d5654d228d1061246b440ef7cf",
"type": "github"
},
"original": {
"owner": "nvmd",
"repo": "argononed",
"type": "github"
}
},
"crane": {
"locked": {
"lastModified": 1773857772,
@ -60,6 +76,21 @@
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
@ -81,13 +112,79 @@
"type": "github"
}
},
"nixos-hardware": {
"locked": {
"lastModified": 1774018263,
"narHash": "sha256-HHYEwK1A22aSaxv2ibhMMkKvrDGKGlA/qObG4smrSqc=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "2d4b4717b2534fad5c715968c1cece04a172b365",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixos-hardware",
"type": "github"
}
},
"nixos-images": {
"inputs": {
"nixos-stable": [
"nixos-raspberrypi",
"nixpkgs"
],
"nixos-unstable": [
"nixos-raspberrypi",
"nixpkgs"
]
},
"locked": {
"lastModified": 1747747741,
"narHash": "sha256-LUOH27unNWbGTvZFitHonraNx0JF/55h30r9WxqrznM=",
"owner": "nvmd",
"repo": "nixos-images",
"rev": "cbbd6db325775096680b65e2a32fb6187c09bbb4",
"type": "github"
},
"original": {
"owner": "nvmd",
"ref": "sdimage-installer",
"repo": "nixos-images",
"type": "github"
}
},
"nixos-raspberrypi": {
"inputs": {
"argononed": "argononed",
"flake-compat": "flake-compat",
"nixos-images": "nixos-images",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1773704510,
"narHash": "sha256-Kq0WPitNekYzouyd8ROlZb63cpSg/+Ep2XxkV0YlABU=",
"owner": "nvmd",
"repo": "nixos-raspberrypi",
"rev": "b5c77d506bed55250a4642ce6c8b395dd29ef06b",
"type": "github"
},
"original": {
"owner": "nvmd",
"ref": "main",
"repo": "nixos-raspberrypi",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773646010,
"narHash": "sha256-iYrs97hS7p5u4lQzuNWzuALGIOdkPXvjz7bviiBjUu8=",
"lastModified": 1773821835,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5b2c2d84341b2afb5647081c1386a80d7a8d8605",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"type": "github"
},
"original": {
@ -101,6 +198,8 @@
"inputs": {
"agenix": "agenix",
"crane": "crane",
"nixos-hardware": "nixos-hardware",
"nixos-raspberrypi": "nixos-raspberrypi",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
@ -112,11 +211,11 @@
]
},
"locked": {
"lastModified": 1773803479,
"narHash": "sha256-GD6i1F2vrSxbsmbS92+8+x3DbHOJ+yrS78Pm4xigW4M=",
"lastModified": 1774149071,
"narHash": "sha256-SYp8NyzwfCO3Guqmu9hPRHR1hwESlQia5nNz3lYo2qA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f17186f52e82ec5cf40920b58eac63b78692ac7c",
"rev": "6a031966eab3bfaa19be9e261eed5b8a79c04b18",
"type": "github"
},
"original": {

240
flake.nix
View file

@ -3,6 +3,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
agenix = {
url = "github:ryantm/agenix";
inputs.nixpkgs.follows = "nixpkgs";
@ -12,12 +13,18 @@
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-raspberrypi = {
url = "github:nvmd/nixos-raspberrypi/main";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
nixos-hardware,
nixos-raspberrypi,
agenix,
crane,
rust-overlay,
@ -99,100 +106,191 @@
}
);
flash-pi-sd = pkgs.writeShellApplication {
name = "flash-pi-sd";
bootstrapModule =
{
lib,
nixos-raspberrypi,
pkgs,
...
}:
{
imports = with nixos-raspberrypi.nixosModules; [
default
usb-gadget-ethernet
];
system.stateVersion = "24.11";
boot.loader.raspberry-pi = {
variant = "02";
firmwarePackage = nixos-raspberrypi.packages.${pkgs.stdenv.hostPlatform.system}.raspberrypifw;
bootloader = "kernel";
};
boot.supportedFilesystems = lib.mkForce [
"ext4"
"vfat"
];
boot.kernelParams = lib.mkAfter [ "cfg80211.ieee80211_regdom=US" ];
networking.hostName = "noisebridge-pi";
networking.networkmanager.enable = lib.mkForce false;
networking.wireless = {
enable = true;
networks."Noisebridge".psk = "noisebridge";
};
services.avahi = {
enable = true;
nssmdns4 = true;
openFirewall = true;
};
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "prohibit-password";
};
};
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu"
];
};
flash-bootstrap-sd = pkgs.writeShellApplication {
name = "flash-bootstrap-sd";
runtimeInputs = [
agenix.packages.${system}.default
pkgs.coreutils
pkgs.nix
pkgs.parted
pkgs.systemd
pkgs.util-linux
pkgs.zstd
];
text = ''
set -euo pipefail
PARTPROBE=${pkgs.parted}/bin/partprobe
MOUNT=${pkgs.util-linux}/bin/mount
UMOUNT=${pkgs.util-linux}/bin/umount
MOUNTPOINT=${pkgs.util-linux}/bin/mountpoint
FINDMNT=${pkgs.util-linux}/bin/findmnt
UDEVADM=${pkgs.systemd}/bin/udevadm
ZSTD=${pkgs.zstd}/bin/zstd
if [ "$#" -ne 1 ]; then
echo "usage: flash-pi-sd /dev/sdX" >&2
echo "usage: flash-bootstrap-sd /dev/sdX" >&2
exit 1
fi
device="$1"
flake_path=${builtins.toString ./.}
image_link="$(mktemp -u /tmp/noisebell-sd-image.XXXXXX)"
mount_dir="$(mktemp -d)"
secrets_dir="${builtins.toString ./secrets}"
key_name="bootstrap-identity.age"
rules_file="${builtins.toString ./secrets/secrets.nix}"
cleanup() {
if "$MOUNTPOINT" -q "$mount_dir"; then
sudo "$UMOUNT" "$mount_dir"
fi
rm -rf "$mount_dir"
rm -f "$image_link"
}
trap cleanup EXIT
zstd_bin=${pkgs.zstd}/bin/zstd
if [ ! -b "$device" ]; then
echo "not a block device: $device" >&2
exit 1
fi
boot_part="''${device}1"
case "$device" in
*[0-9]) boot_part="''${device}p1" ;;
esac
echo "Requesting sudo access before build and flash..."
sudo -v
echo "Sudo authentication successful."
echo "Building bootstrap SD image..."
nix build "$flake_path#nixosConfigurations.bootstrap.config.system.build.sdImage" -o "$image_link"
echo "Building bootstrap NixOS Raspberry Pi Zero 2 W image..."
image_out="$(nix build \
--print-out-paths \
--cores 0 \
--max-jobs auto \
"$flake_path#nixosConfigurations.bootstrap.config.system.build.sdImage")"
image="$(echo "$image_link"/sd-image/*.img*)"
image="$(echo "$image_out"/sd-image/*.img*)"
if [ ! -f "$image" ]; then
echo "failed to locate SD image under $image_link/sd-image" >&2
echo "failed to locate SD image under $image_out/sd-image" >&2
exit 1
fi
echo "Flashing $image to $device..."
if [ "''${image##*.}" = "zst" ]; then
"$ZSTD" -d --stdout "$image" | sudo dd of="$device" bs=4M conv=fsync status=progress
"$zstd_bin" -d --stdout "$image" | sudo dd of="$device" bs=16M conv=fsync status=progress
else
sudo dd if="$image" of="$device" bs=4M conv=fsync status=progress
sudo dd if="$image" of="$device" bs=16M conv=fsync status=progress
fi
sync
sudo "$PARTPROBE" "$device"
sudo "$UDEVADM" settle
echo "Done. This is the custom bootstrap NixOS image."
'';
};
if "$FINDMNT" -rn "$boot_part" >/dev/null 2>&1; then
sudo "$UMOUNT" "$boot_part"
pi-serial = pkgs.writeShellApplication {
name = "pi-serial";
runtimeInputs = [
pkgs.coreutils
pkgs.procps
pkgs.tio
pkgs.util-linux
];
text = ''
set -euo pipefail
baud_rate=115200
data_bits=8
stop_bits=1
parity=none
flow_control=none
serial_tools="screen tio minicom picocom"
port=""
if [ "$#" -gt 1 ]; then
echo "usage: pi-serial [device]" >&2
exit 1
fi
echo "Installing bootstrap age identity onto $boot_part..."
sudo "$MOUNT" "$boot_part" "$mount_dir"
(
cd "$secrets_dir"
RULES="$rules_file" agenix -d "$key_name"
) | sudo tee "$mount_dir/noisebell-bootstrap.agekey" >/dev/null
sudo chmod 600 "$mount_dir/noisebell-bootstrap.agekey"
sync
if [ "$#" -eq 1 ]; then
port="$1"
else
for candidate in /dev/serial/by-id/* /dev/ttyUSB* /dev/ttyACM*; do
if [ -e "$candidate" ]; then
port="$candidate"
break
fi
done
fi
echo "Done. You can now move the card to the Pi and boot it."
if [ -z "$port" ]; then
echo "No serial device found." >&2
echo "Check the adapter and run: ls -l /dev/serial/by-id /dev/ttyUSB* /dev/ttyACM* 2>/dev/null" >&2
exit 1
fi
log_file="pi-serial-$(date +%Y%m%d-%H%M%S).log"
echo "Stopping old serial sessions for this user"
for tool in $serial_tools; do
pkill -x -u "$USER" "$tool" 2>/dev/null || true
done
sleep 1
echo "Waiting for port to become free: $port"
while fuser "$port" >/dev/null 2>&1; do
sleep 1
done
echo "Using serial port: $port"
echo "Logging to: $log_file"
echo "Start this before powering the Pi."
exec sudo tio \
-b "$baud_rate" \
-d "$data_bits" \
-s "$stop_bits" \
-p "$parity" \
-f "$flow_control" \
-t \
--log \
--log-file "$log_file" \
"$port"
'';
};
in
{
packages.${system} = {
inherit noisebell-cache noisebell-discord flash-pi-sd;
inherit
noisebell-cache
noisebell-discord
flash-bootstrap-sd
pi-serial
;
default = noisebell-cache;
};
@ -208,6 +306,9 @@
imports = [
(import ./remote/cache-service/module.nix noisebell-cache)
(import ./remote/discord-bot/module.nix noisebell-discord)
(import ./remote/hosted-module.nix {
inherit self agenix;
})
];
};
};
@ -216,6 +317,7 @@
system = "aarch64-linux";
modules = [
agenix.nixosModules.default
nixos-hardware.nixosModules.raspberry-pi-3
(import ./pi/module.nix {
pkg = noisebell-pi;
rev = self.shortRev or "dirty";
@ -225,34 +327,38 @@
];
};
nixosConfigurations.bootstrap = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
nixosConfigurations.bootstrap = nixos-raspberrypi.lib.nixosSystem {
specialArgs = {
inherit nixos-raspberrypi;
};
modules = [
agenix.nixosModules.default
(import ./pi/module.nix {
pkg = noisebell-pi;
rev = self.shortRev or "dirty";
})
./pi/bootstrap.nix
nixos-raspberrypi.nixosModules.sd-image
bootstrapModule
];
};
devShells.${system}.default = craneLib.devShell {
packages = [
flash-pi-sd
flash-bootstrap-sd
pi-serial
pkgs.nix
pkgs.parted
pkgs.rust-analyzer
pkgs.systemd
pkgs.util-linux
pkgs.tio
pkgs.zstd
agenix.packages.${system}.default
];
};
apps.${system}.flash-pi-sd = {
apps.${system} = {
flash-bootstrap-sd = {
type = "app";
program = "${flash-pi-sd}/bin/flash-pi-sd";
program = "${flash-bootstrap-sd}/bin/flash-bootstrap-sd";
};
pi-serial = {
type = "app";
program = "${pi-serial}/bin/pi-serial";
};
};
};
}

View file

@ -1,8 +0,0 @@
{ modulesPath, ... }:
{
imports = [
"${modulesPath}/installer/sd-card/sd-image-aarch64.nix"
./configuration.nix
];
}

View file

@ -10,7 +10,11 @@
networks."Noisebridge".psk = "noisebridge";
};
services.avahi.enable = false;
services.avahi = {
enable = true;
nssmdns4 = true;
openFirewall = true;
};
# Decrypted at runtime by agenix
age.identityPaths = [
@ -30,11 +34,23 @@
inboundApiKeyFile = config.age.secrets.cache-to-pi-key.path;
};
hardware.enableRedistributableFirmware = true;
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
boot.kernelParams = [
"console=ttyS0,115200n8"
"console=ttyAMA0,115200n8"
"console=tty0"
"boot.shell_on_fail"
"loglevel=7"
"systemd.log_level=debug"
"systemd.log_target=console"
];
services.tailscale = {
enable = true;
authKeyFile = config.age.secrets.tailscale-auth-key.path;
@ -42,6 +58,18 @@
services.openssh.enable = true;
system.activationScripts.pi-zero-2-dtb-compat.text = ''
for dtb_dir in /boot/nixos/*-dtbs/broadcom; do
if [ -d "$dtb_dir" ]; then
if [ -f "$dtb_dir/bcm2837-rpi-zero-2-w.dtb" ] && [ ! -e "$dtb_dir/bcm2837-rpi-zero-2.dtb" ]; then
cp "$dtb_dir/bcm2837-rpi-zero-2-w.dtb" "$dtb_dir/bcm2837-rpi-zero-2.dtb"
elif [ -f "$dtb_dir/bcm2837-rpi-3-b.dtb" ] && [ ! -e "$dtb_dir/bcm2837-rpi-zero-2.dtb" ]; then
cp "$dtb_dir/bcm2837-rpi-3-b.dtb" "$dtb_dir/bcm2837-rpi-zero-2.dtb"
fi
fi
done
'';
networking.firewall = {
trustedInterfaces = [ "tailscale0" ];
allowedUDPPorts = [ config.services.tailscale.port ];

View file

@ -1,4 +1,10 @@
{ config, lib, pkgs, modulesPath, ... }:
{
config,
lib,
pkgs,
modulesPath,
...
}:
{
imports = [ ];

View file

@ -1 +0,0 @@
/nix/store/pmrzmz2b2hsffk62icl3c0ni56gpi3qs-nixos-image-sd-card-26.05.20260308.9dcb002-aarch64-linux.img.zst

View file

@ -25,11 +25,11 @@ nix build .#noisebell-discord
## NixOS deployment
The flake exports NixOS modules. Each service runs as a hardened systemd unit behind Caddy.
The flake exports a NixOS module for the hosted remote machine. It imports `agenix`, declares the Noisebell secrets from `secrets/*.age`, and wires the cache and Discord services together with sensible defaults. Each service runs as a hardened systemd unit behind Caddy.
```nix
{
inputs.noisebell.url = "git+https://git.extremist.software/jet/noisebell?dir=remote";
inputs.noisebell.url = "git+https://git.extremist.software/jet/noisebell";
outputs = { self, nixpkgs, noisebell, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@ -41,19 +41,11 @@ The flake exports NixOS modules. Each service runs as a hardened systemd unit be
enable = true;
domain = "cache.noisebell.example.com";
piAddress = "http://noisebell-pi:80";
piApiKeyFile = "/run/secrets/noisebell-pi-api-key";
inboundApiKeyFile = "/run/secrets/noisebell-inbound-api-key";
outboundWebhooks = [{
url = "http://localhost:3001/webhook";
secretFile = "/run/secrets/noisebell-discord-webhook-secret";
}];
};
services.noisebell-discord = {
enable = true;
domain = "discord.noisebell.example.com";
discordTokenFile = "/run/secrets/noisebell-discord-token";
channelId = "123456789012345678";
webhookSecretFile = "/run/secrets/noisebell-discord-webhook-secret";
};
})
];
@ -61,3 +53,14 @@ The flake exports NixOS modules. Each service runs as a hardened systemd unit be
};
}
```
`nixosModules.default` handles these secrets automatically:
| Secret file | Deployed on | Used for |
|-------------|-------------|----------|
| `secrets/pi-to-cache-key.age` | Pi + remote | Pi authenticates to cache `/webhook` |
| `secrets/cache-to-pi-key.age` | Pi + remote | cache authenticates to Pi GET endpoints |
| `secrets/discord-webhook-secret.age` | remote | cache authenticates to Discord bot `/webhook` |
| `secrets/discord-token.age` | remote | Discord bot login |
When `extremist-software` builds a system using the Noisebell flake input, Nix uses the checked-out flake source for that input. The module points `agenix` at encrypted files inside that Noisebell source tree, such as `${inputs.noisebell}/secrets/discord-token.age`. At activation time `agenix` decrypts them locally on the target host into runtime paths like `/run/agenix/noisebell-discord-token`. The service modules then read those local decrypted files when systemd starts them.

61
remote/hosted-module.nix Normal file
View file

@ -0,0 +1,61 @@
{ self, agenix }:
{ config, lib, ... }:
let
cfgCache = config.services.noisebell-cache;
cfgDiscord = config.services.noisebell-discord;
in
{
imports = [ agenix.nixosModules.default ];
users.groups.noisebell = { };
users.users.noisebell-cache.extraGroups = lib.mkIf cfgCache.enable [ "noisebell" ];
users.users.noisebell-discord.extraGroups = lib.mkIf cfgDiscord.enable [ "noisebell" ];
age.secrets.noisebell-pi-to-cache-key = {
file = "${self}/secrets/pi-to-cache-key.age";
group = "noisebell";
mode = "0440";
};
age.secrets.noisebell-cache-to-pi-key = {
file = "${self}/secrets/cache-to-pi-key.age";
group = "noisebell";
mode = "0440";
};
age.secrets.noisebell-discord-token = {
file = "${self}/secrets/discord-token.age";
group = "noisebell";
mode = "0440";
};
age.secrets.noisebell-discord-webhook-secret = {
file = "${self}/secrets/discord-webhook-secret.age";
group = "noisebell";
mode = "0440";
};
services.noisebell-cache = lib.mkIf cfgCache.enable {
piApiKeyFile = lib.mkDefault config.age.secrets.noisebell-cache-to-pi-key.path;
inboundApiKeyFile = lib.mkDefault config.age.secrets.noisebell-pi-to-cache-key.path;
outboundWebhooks = lib.mkDefault (
lib.optional cfgDiscord.enable {
url = "http://127.0.0.1:${toString cfgDiscord.port}/webhook";
secretFile = config.age.secrets.noisebell-discord-webhook-secret.path;
}
);
};
services.noisebell-discord = lib.mkIf cfgDiscord.enable (
{
discordTokenFile = lib.mkDefault config.age.secrets.noisebell-discord-token.path;
webhookSecretFile = lib.mkDefault config.age.secrets.noisebell-discord-webhook-secret.path;
}
// lib.optionalAttrs cfgCache.enable {
cacheUrl = lib.mkDefault "http://127.0.0.1:${toString cfgCache.port}";
imageBaseUrl = lib.mkDefault "https://${cfgCache.domain}/image";
}
);
}

98
scripts/configure-pios-sd.sh Executable file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
BOOTFS=${1:-/run/media/jet/bootfs}
ROOTFS=${2:-/run/media/jet/rootfs}
HOSTNAME=noisebridge-pi
WIFI_SSID=Noisebridge
WIFI_PASSWORD=noisebridge
PI_USERNAME=pi
PI_PASSWORD=noisebridge
SSH_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu'
if [[ $EUID -ne 0 ]]; then
echo "Run with sudo: sudo $0 [bootfs] [rootfs]" >&2
exit 1
fi
if [[ ! -d "$BOOTFS" || ! -d "$ROOTFS" ]]; then
echo "Expected mounted boot and root partitions." >&2
echo "Boot: $BOOTFS" >&2
echo "Root: $ROOTFS" >&2
exit 1
fi
PASSWORD_HASH=$(mkpasswd -m sha-512 "$PI_PASSWORD")
cat > "$BOOTFS/network-config" <<EOF
network:
version: 2
wifis:
wlan0:
dhcp4: true
optional: true
access-points:
${WIFI_SSID}:
password: "${WIFI_PASSWORD}"
regulatory-domain: US
EOF
cat > "$BOOTFS/user-data" <<EOF
#cloud-config
hostname: ${HOSTNAME}
manage_etc_hosts: true
ssh_pwauth: false
package_update: false
packages:
- avahi-daemon
users:
- name: pi
ssh_authorized_keys:
- ${SSH_KEY}
runcmd:
- [ systemctl, enable, --now, avahi-daemon ]
EOF
cat > "$BOOTFS/meta-data" <<EOF
dsmode: local
instance_id: ${HOSTNAME}-bootstrap-1
EOF
grep -q '^enable_uart=1$' "$BOOTFS/config.txt" || printf '\nenable_uart=1\n' >> "$BOOTFS/config.txt"
: > "$BOOTFS/ssh"
cat > "$BOOTFS/userconf.txt" <<EOF
${PI_USERNAME}:${PASSWORD_HASH}
EOF
cat > "$ROOTFS/etc/hostname" <<EOF
${HOSTNAME}
EOF
cat > "$ROOTFS/etc/hosts" <<EOF
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
127.0.1.1 ${HOSTNAME}
EOF
mkdir -p "$ROOTFS/home/pi/.ssh"
cat > "$ROOTFS/home/pi/.ssh/authorized_keys" <<EOF
${SSH_KEY}
EOF
chown -R 1000:1000 "$ROOTFS/home/pi/.ssh"
chmod 700 "$ROOTFS/home/pi/.ssh"
chmod 600 "$ROOTFS/home/pi/.ssh/authorized_keys"
sync
echo "Configured Raspberry Pi OS SD card."
echo "- Hostname: ${HOSTNAME}"
echo "- Wi-Fi: ${WIFI_SSID}"
echo "- SSH enabled on first boot"
echo "- Serial UART enabled"
echo "- Username: ${PI_USERNAME}"
echo "- Password: ${PI_PASSWORD}"
echo "- Pi user authorized key installed"

Binary file not shown.