My nix-darwin and NixOS config
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: pds prerequisite

+771 -133
+183 -95
docs/hosts-server.md
··· 1 1 # Server Host 2 2 3 - > **⚠️ Note**: This is a planned configuration that has not yet been deployed to hardware. The configuration is maintained and ready for deployment when needed. 3 + > **⚠️ Note**: This is a planned configuration that has not yet been deployed to hardware. The configuration is ready; follow the runbook below. 4 4 5 - Minimal NixOS server configuration with security hardening and automatic maintenance. 5 + Hardened NixOS server running a Bluesky ATProto PDS, exposed via a Cloudflare tunnel (no open inbound ports except SSH). 6 6 7 - ## Features 7 + ## Architecture 8 8 9 - **Included:** 10 - - SSH server — key-based authentication only, hardened settings 11 - - Fail2ban — automatic brute-force protection 12 - - Firewall — SSH-only by default 13 - - Auto-upgrades — daily, via `settings/config/maintenance.nix` 14 - - Monitoring tools — btop, iotop, iftop, smartmontools, network tools 15 - - SMART disk monitoring 16 - - Weekly SSD TRIM 17 - - Log rotation and garbage collection 18 - - Zsh shell via shared Home Manager config 9 + ``` 10 + Internet → Cloudflare edge (TLS) 11 + ↓ encrypted tunnel (outbound from server) 12 + cloudflared daemon 13 + ↓ HTTP 14 + Caddy (127.0.0.1:2020) 15 + ↓ age-assurance static responses (UK OSA) 16 + ↓ reverse proxy 17 + bluesky-pds (127.0.0.1:3000) 18 + ``` 19 19 20 - **Not included:** 21 - - No desktop environment or GUI 22 - - No gaming or multimedia packages 20 + No ports 80/443 need to be open in the firewall. SSH is the only public port. 23 21 24 - ## Installation 22 + --- 25 23 26 - ### 1. Generate hardware config 24 + ## Pre-Deploy Checklist (do these NOW, before the server exists) 27 25 28 - Boot the NixOS installer, partition disks, mount them, then: 26 + These steps interact only with Cloudflare and your local machine — the server 27 + doesn't need to exist yet. 28 + 29 + ### 1. Generate PDS secrets 30 + 31 + If you haven't already done this (check whether `secrets/age/pds.env.age` is 32 + populated with real secrets — not a placeholder): 29 33 30 34 ```bash 31 - sudo nixos-generate-config --show-hardware-config > /tmp/hardware-configuration.nix 35 + # Generate each secret separately — do NOT reuse values 36 + PDS_JWT_SECRET=$(openssl rand --hex 16) 37 + PDS_ADMIN_PASSWORD=$(openssl rand --hex 16) 38 + PDS_PLC_ROTATION_KEY=$(openssl ecparam --name secp256k1 --genkey --noout \ 39 + --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32) 40 + 41 + # Edit the secret file (ragenix opens $EDITOR): 42 + nix run github:yaxitech/ragenix -- \ 43 + --rules secrets/secrets.nix \ 44 + --editor "code --wait" \ 45 + -e secrets/age/pds.env.age 46 + ``` 47 + 48 + The file should contain (one per line): 49 + 50 + ``` 51 + PDS_JWT_SECRET=<value> 52 + PDS_ADMIN_PASSWORD=<value> 53 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=<value> 54 + PDS_EMAIL_SMTP_URL=smtps://resend:<api-key>@smtp.resend.com:465/ 55 + PDS_EMAIL_FROM_ADDRESS=pds@ewancroft.uk 32 56 ``` 33 57 34 - Copy the output to `hosts/server/hardware-configuration.nix`. 58 + ### 2. Create the Cloudflare tunnel 59 + 60 + Run this on your **macmini or laptop** (not the server — it doesn't exist yet): 61 + 62 + ```bash 63 + # Authenticate with your Cloudflare account (opens browser) 64 + cloudflared tunnel login 35 65 36 - ### 2. Configure SSH keys 66 + # Create the tunnel — note the UUID printed in the output 67 + cloudflared tunnel create pds 68 + # → Created tunnel pds with id XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 69 + ``` 37 70 38 - SSH public keys are managed in `modules/ssh-keys.nix`. Add the server's key there. 71 + ### 3. Update the tunnel UUID in settings 39 72 40 - ### 3. Customise settings 73 + Edit `settings/config/pds.nix` and replace the placeholder UUID: 74 + 75 + ```nix 76 + cloudflare = { 77 + tunnelId = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"; # ← paste real UUID here 78 + }; 79 + ``` 41 80 42 - All server-specific values live in `settings/config/server.nix`: 43 - - `sshd.port` — SSH port (default: 22) 44 - - `firewall.allowedTCPPorts` — open ports 45 - - `fail2ban.banTime` / `fail2ban.maxRetry` — intrusion thresholds 81 + ### 4. Encrypt the tunnel credentials 46 82 47 - ### 4. Install from USB/ISO 83 + The JSON credentials file is at `~/.cloudflared/<UUID>.json` after step 2: 48 84 49 85 ```bash 50 - # On the installer 51 - cd /mnt/etc/nixos 52 - curl -L https://github.com/ewanc26/nix/archive/refs/heads/main.tar.gz | sudo tar -xz --strip-components=1 53 - sudo nixos-install --flake .#server 54 - reboot 86 + cp ~/.cloudflared/<UUID>.json /tmp/cf-tunnel-pds.json 87 + 88 + nix run github:yaxitech/ragenix -- \ 89 + --rules secrets/secrets.nix \ 90 + --editor "code --wait" \ 91 + -e secrets/age/cf-tunnel-pds.json.age 92 + 93 + # Paste the JSON file contents into the editor, save and close. 94 + # Delete the plaintext copy: 95 + rm /tmp/cf-tunnel-pds.json 96 + ``` 97 + 98 + ### 5. Add the DNS CNAME in Cloudflare 99 + 100 + In the Cloudflare dashboard (or via `cloudflared tunnel route dns`): 101 + 102 + ``` 103 + pds.ewancroft.uk CNAME <UUID>.cfargotunnel.com (proxied ✓) 104 + *.ewancroft.uk CNAME <UUID>.cfargotunnel.com (proxied ✓) 55 105 ``` 56 106 57 - ### 5. Or switch from existing NixOS 107 + The `*.ewancroft.uk` wildcard lets users choose `@user.ewancroft.uk` handles. 108 + 109 + --- 110 + 111 + ## Deploy Day 112 + 113 + ### 1. Boot the NixOS installer on the server 114 + 115 + Partition and mount disks, then generate the hardware config: 58 116 59 117 ```bash 60 - cd /tmp 61 - curl -L https://github.com/ewanc26/nix/archive/refs/heads/main.tar.gz | tar -xz 62 - mv nix-config-main nix-config && cd nix-config 63 - sudo nixos-generate-config --show-hardware-config > hosts/server/hardware-configuration.nix 64 - sudo nixos-rebuild switch --flake .#server 65 - sudo cp -r . /home/ewan/.config/nix-config 118 + sudo nixos-generate-config --show-hardware-config 66 119 ``` 67 120 68 - ## Post-Installation 121 + Copy the output into `hosts/server/minimal-hardware.nix` (replacing the 122 + placeholder content), commit, and push. 69 123 70 - ### Verify SSH 124 + ### 2. Get the server age key 125 + 126 + While still on the installer (or after first boot): 71 127 72 128 ```bash 73 - ssh ewan@your-server-ip 129 + nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age' 74 130 ``` 75 131 76 - ### Check services 132 + Paste the result into `secrets/secrets.nix`: 133 + 134 + ```nix 135 + systems = { 136 + # ... 137 + server = "age1..."; # ← paste here 138 + }; 139 + ``` 140 + 141 + Also change `pdsKeys` from `[ users.ewan ]` to `[ users.ewan systems.server ]`. 142 + 143 + ### 3. Rekey secrets for the server 144 + 145 + From your macmini or laptop (you need your private age key): 77 146 78 147 ```bash 79 - systemctl status sshd 80 - systemctl status fail2ban 81 - sudo iptables -L -n 148 + cd ~/.config/nix-config 149 + nix run github:yaxitech/ragenix -- --rules secrets/secrets.nix --rekey 150 + git add secrets/age/ secrets/secrets.nix 151 + git commit -m "secrets: add server key and rekey PDS secrets" 152 + git push 82 153 ``` 83 154 84 - ### Manual update 155 + ### 4. Install NixOS 85 156 86 157 ```bash 87 - sudo nixos-rebuild switch --flake /home/ewan/.config/nix-config#server 158 + # On the server installer, clone the repo and install: 159 + nix-shell -p git 160 + git clone https://github.com/ewanc26/nix /mnt/etc/nixos 161 + nixos-install --flake /mnt/etc/nixos#server 162 + reboot 88 163 ``` 89 164 90 - ## Security 165 + ### 5. Verify on the server 91 166 92 - | Hardening | Status | 93 - |---|---| 94 - | Root login disabled | ✅ | 95 - | Password auth disabled | ✅ | 96 - | Fail2ban active | ✅ | 97 - | AllowUsers restricted | ✅ | 98 - | Connection timeouts | ✅ | 99 - | Firewall enabled | ✅ | 167 + ```bash 168 + # Check all services are up 169 + systemctl status bluesky-pds 170 + systemctl status cloudflared 171 + systemctl status caddy 172 + 173 + # Check the PDS is reachable via the tunnel 174 + curl https://pds.ewancroft.uk/xrpc/_health 100 175 101 - To open additional ports, edit `settings/config/server.nix` → `firewall.allowedTCPPorts`. 176 + # Check UK OSA age-assurance endpoints work 177 + curl https://pds.ewancroft.uk/xrpc/app.bsky.ageassurance.getConfig 178 + ``` 102 179 103 - ## Maintenance 180 + ### 6. Create your account 104 181 105 182 ```bash 106 - # Disk health 107 - sudo smartctl -a /dev/sda 183 + # Install atproto-goat (already in systemPackages on the server) 184 + # Create an invite code first, then create the account 185 + atproto-goat --pds-host https://pds.ewancroft.uk account create 186 + ``` 108 187 109 - # View logs 110 - journalctl -xe 111 - journalctl -u sshd 112 - journalctl -u fail2ban 188 + --- 189 + 190 + ## Key settings 113 191 114 - # Check banned IPs 115 - sudo fail2ban-client status sshd 192 + All non-secret PDS settings live in `settings/config/pds.nix`: 116 193 117 - # Garbage collection (auto-runs weekly) 118 - sudo nix-collect-garbage --delete-older-than 30d 119 - ``` 194 + | Setting | Value | 195 + |---|---| 196 + | Hostname | `pds.ewancroft.uk` | 197 + | Handle domains | `.ewancroft.uk` | 198 + | PDS port | `3000` (internal only) | 199 + | Caddy port | `2020` (internal only) | 200 + | Tunnel ID | set in `settings/config/pds.nix` | 120 201 121 - ## Common Customisations 202 + --- 122 203 123 - ### Add a web server 124 - 1. Add `80` and `443` to `settings/config/server.nix` → `firewall.allowedTCPPorts` 125 - 2. Add nginx config to `hosts/server/default.nix` 126 - 3. Rebuild 204 + ## Security 127 205 128 - ### Change SSH port 129 - Edit `settings/config/server.nix` → `sshd.port` (firewall updates automatically from the same value). 206 + | Hardening | Status | 207 + |---|---| 208 + | Root login disabled | ✅ | 209 + | Password auth disabled | ✅ | 210 + | Fail2ban active | ✅ | 211 + | SSH key-only | ✅ | 212 + | Firewall: SSH only | ✅ | 213 + | No public HTTP/HTTPS ports | ✅ (Cloudflare tunnel) | 214 + | Secrets age-encrypted | ✅ | 215 + | PDS secrets server-only | ✅ (after rekeying) | 130 216 131 - ### Add a user 132 - Edit `hosts/server/default.nix` and add an entry to `users.users`. 217 + --- 133 218 134 - ## Troubleshooting 219 + ## Ongoing maintenance 135 220 136 - **Can't SSH in:** 137 221 ```bash 138 - sudo iptables -L 139 - systemctl status sshd 140 - sudo sshd -T 222 + # Manual rebuild 223 + sudo nixos-rebuild switch --flake /home/ewan/.config/nix-config#server 224 + 225 + # PDS logs 226 + journalctl -u bluesky-pds -f 227 + 228 + # Tunnel status 229 + journalctl -u cloudflared -f 230 + 231 + # Check banned IPs 141 232 sudo fail2ban-client status sshd 142 - ``` 143 233 144 - **System not upgrading:** 145 - ```bash 146 - systemctl status nixos-upgrade.timer 147 - journalctl -u nixos-upgrade.service 148 - sudo systemctl start nixos-upgrade.service 234 + # Disk health 235 + sudo smartctl -a /dev/sda 149 236 ``` 150 237 151 238 ## Resources 152 239 153 - - [NixOS Manual](https://nixos.org/manual/nixos/stable/) 154 - - [NixOS Security Wiki](https://nixos.wiki/wiki/Security) 155 - - [SSH hardening](https://nixos.wiki/wiki/SSH_public_key_authentication) 240 + - [isabelroses PDS guide](https://isabelroses.com/blog/nix-pds-guide/) — basis for this config 241 + - [Cloudflare tunnel docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 242 + - [ATProto PDS environment variables](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/config/env.ts) 243 + - [UK OSA age-assurance gist](https://gist.github.com/mary-ext/6e27b24a83838202908808ad528b3318)
+144
modules/pds.nix
··· 1 + ############################################################################## 2 + # Bluesky ATProto Personal Data Server — NixOS module. 3 + # 4 + # Architecture: 5 + # PDS (127.0.0.1:cfg.port) 6 + # ↑ reverse proxy 7 + # Caddy (127.0.0.1:cfg.caddyPort — internal only, no TLS here) 8 + # ↑ Cloudflare tunnel (cloudflared — outbound only, no firewall ports needed) 9 + # 10 + # Non-secret settings live in settings/config/pds.nix. 11 + # Secrets decrypted by ragenix at activation time. 12 + # 13 + # Required secrets (set in secrets/age/pds.env.age as KEY=value pairs): 14 + # PDS_JWT_SECRET openssl rand --hex 16 15 + # PDS_ADMIN_PASSWORD openssl rand --hex 16 16 + # PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX 17 + # openssl ecparam --name secp256k1 --genkey --noout --outform DER \ 18 + # | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32 19 + # PDS_EMAIL_SMTP_URL (optional — for email verification) 20 + # PDS_EMAIL_FROM_ADDRESS (optional — for email verification) 21 + # 22 + # Cloudflare tunnel setup (one-time, outside Nix): 23 + # 1. cloudflared tunnel login 24 + # 2. cloudflared tunnel create pds 25 + # 3. Encrypt the resulting ~/.cloudflared/<UUID>.json with ragenix: 26 + # nix run github:yaxitech/ragenix -- -e secrets/age/cf-tunnel-pds.json.age 27 + # 4. Set cfg.cloudflare.tunnelId to that UUID in settings/config/pds.nix. 28 + # 5. Add a CNAME in Cloudflare DNS: <hostname> → <UUID>.cfargotunnel.com 29 + ############################################################################## 30 + { config, lib, pkgs, ... }: 31 + 32 + let 33 + cfg = (import ../../settings/config.nix).pds; 34 + pdsPort = toString cfg.port; 35 + pdsHost = cfg.hostname; 36 + caddyPort = toString cfg.caddyPort; 37 + 38 + # UK Online Safety Act age-assurance static responses. 39 + # Required for UK-based PDS instances (Online Safety Act 2023). 40 + # Source: https://gist.github.com/mary-ext/6e27b24a83838202908808ad528b3318 41 + ageAssuranceBlocks = '' 42 + handle /xrpc/app.bsky.unspecced.getAgeAssuranceState { 43 + header Content-Type "application/json" 44 + header Access-Control-Allow-Headers "authorization,dpop,atproto-accept-labelers,atproto-proxy" 45 + header Access-Control-Allow-Origin "*" 46 + respond `{"lastInitiatedAt":"2025-07-14T14:22:43.912Z","status":"assured"}` 200 47 + } 48 + handle /xrpc/app.bsky.ageassurance.getConfig { 49 + header Content-Type "application/json" 50 + header Access-Control-Allow-Headers "authorization,dpop,atproto-accept-labelers,atproto-proxy" 51 + header Access-Control-Allow-Origin "*" 52 + respond `{"regions":[]}` 200 53 + } 54 + handle /xrpc/app.bsky.ageassurance.getState { 55 + header Content-Type "application/json" 56 + header Access-Control-Allow-Headers "authorization,dpop,atproto-accept-labelers,atproto-proxy" 57 + header Access-Control-Allow-Origin "*" 58 + respond `{"state":{"lastInitiatedAt":"2025-07-14T14:22:43.912Z","status":"assured","access":"full"},"metadata":{"accountCreatedAt":"2022-11-17T00:35:16.391Z"}}` 200 59 + } 60 + ''; 61 + in 62 + lib.mkIf cfg.enable { 63 + 64 + # ── Secrets ────────────────────────────────────────────────────────────────── 65 + age.secrets."pds.env" = { 66 + file = ../../secrets/age/pds.env.age; 67 + owner = "pds"; 68 + group = "pds"; 69 + mode = "0400"; 70 + }; 71 + 72 + # JSON credentials file created by `cloudflared tunnel create pds`. 73 + # Encrypted with: nix run github:yaxitech/ragenix -- -e secrets/age/cf-tunnel-pds.json.age 74 + age.secrets."cf-tunnel-pds.json" = { 75 + file = ../../secrets/age/cf-tunnel-pds.json.age; 76 + owner = "cloudflared"; 77 + mode = "0400"; 78 + }; 79 + 80 + # ── PDS service ─────────────────────────────────────────────────────────────── 81 + environment.systemPackages = [ pkgs.atproto-goat ]; 82 + 83 + services.bluesky-pds = { 84 + enable = true; 85 + environmentFiles = [ config.age.secrets."pds.env".path ]; 86 + settings = { 87 + PDS_PORT = cfg.port; 88 + PDS_HOSTNAME = cfg.hostname; 89 + PDS_ADMIN_EMAIL = cfg.adminEmail; 90 + PDS_SERVICE_HANDLE_DOMAINS = 91 + lib.concatStringsSep "," cfg.serviceHandleDomains; 92 + PDS_CRAWLERS = 93 + lib.concatStringsSep "," cfg.crawlers; 94 + }; 95 + }; 96 + 97 + systemd.services.bluesky-pds = { 98 + serviceConfig.Restart = "always"; 99 + serviceConfig.RestartSec = cfg.restartSec; 100 + unitConfig = { 101 + StartLimitIntervalSec = cfg.startLimitIntervalSec; 102 + StartLimitBurst = cfg.startLimitBurst; 103 + }; 104 + }; 105 + 106 + # ── Caddy reverse proxy ─────────────────────────────────────────────────────── 107 + # Listens on localhost:caddyPort only — never exposed publicly. 108 + # Cloudflare handles TLS; Caddy receives plain HTTP from the tunnel daemon. 109 + # Using http:// prefix disables Caddy's automatic HTTPS / ACME entirely. 110 + services.caddy = { 111 + enable = true; 112 + # Global config: disable automatic HTTPS since Cloudflare handles TLS. 113 + globalConfig = '' 114 + auto_https off 115 + ''; 116 + virtualHosts."http://127.0.0.1:${caddyPort}" = { 117 + extraConfig = '' 118 + ${ageAssuranceBlocks} 119 + handle { 120 + reverse_proxy http://127.0.0.1:${pdsPort} 121 + } 122 + ''; 123 + }; 124 + }; 125 + 126 + # ── Cloudflare tunnel ───────────────────────────────────────────────────────── 127 + # cloudflared dials outbound to Cloudflare's edge — zero inbound ports needed. 128 + # Replace the UUID string with the one from `cloudflared tunnel create pds`. 129 + services.cloudflared = { 130 + enable = true; 131 + tunnels.${cfg.cloudflare.tunnelId} = { 132 + credentialsFile = config.age.secrets."cf-tunnel-pds.json".path; 133 + default = "http_status:404"; 134 + ingress = { 135 + ${pdsHost} = "http://127.0.0.1:${caddyPort}"; 136 + "*.${pdsHost}" = "http://127.0.0.1:${caddyPort}"; 137 + }; 138 + }; 139 + }; 140 + 141 + # ── Firewall ────────────────────────────────────────────────────────────────── 142 + # The Cloudflare tunnel is fully outbound — no ports need to be open. 143 + # SSH is handled by modules/server/firewall.nix and modules/server/ssh.nix. 144 + }
+354
scripts/pds-setup.sh
··· 1 + #!/usr/bin/env bash 2 + # ============================================================================= 3 + # pds-setup.sh — Hands-off pre-deploy PDS setup 4 + # 5 + # Runs entirely on your macmini/laptop BEFORE the server exists. 6 + # Idempotent by default; use --force-* flags to redo individual steps. 7 + # 8 + # Usage: 9 + # bash ./scripts/pds-setup.sh [flags] 10 + # 11 + # Flags: 12 + # --force-settings Re-prompt for hostname / port / email and repatch pds.nix 13 + # --force-secrets Regenerate and re-encrypt pds.env.age 14 + # --force-tunnel Delete the existing Cloudflare tunnel and recreate it 15 + # --force-dns Re-encrypt credentials and re-patch tunnelId (implies new tunnel) 16 + # --force-all All of the above 17 + # --help Show this message 18 + # 19 + # SMTP env shortcut (skip the interactive SMTP prompt): 20 + # PDS_EMAIL_SMTP_URL=smtps://resend:<key>@smtp.resend.com:465/ \ 21 + # PDS_EMAIL_FROM_ADDRESS=pds@ewancroft.uk \ 22 + # bash ./scripts/pds-setup.sh 23 + # ============================================================================= 24 + set -euo pipefail 25 + 26 + # ── Colour helpers ──────────────────────────────────────────────────────────── 27 + 28 + BOLD=$'\e[1m'; DIM=$'\e[2m'; GREEN=$'\e[32m' 29 + YELLOW=$'\e[33m'; RED=$'\e[31m'; CYAN=$'\e[36m'; RESET=$'\e[0m' 30 + 31 + log() { echo; echo "${BOLD}${CYAN}==> $*${RESET}"; } 32 + ok() { echo "${GREEN} ✓${RESET} $*"; } 33 + warn() { echo "${YELLOW} ⚠${RESET} $*"; } 34 + skip() { echo "${DIM} –${RESET} $*${DIM} (already done — use --force to redo)${RESET}"; } 35 + fail() { echo "${RED} ✗${RESET} $*" >&2; exit 1; } 36 + section(){ echo; echo "${BOLD}${YELLOW} >${RESET} $*"; } 37 + 38 + # ── Argument parsing ────────────────────────────────────────────────────────── 39 + 40 + FORCE_SETTINGS=false 41 + FORCE_SECRETS=false 42 + FORCE_TUNNEL=false 43 + FORCE_DNS=false 44 + 45 + for arg in "$@"; do case "$arg" in 46 + --force-settings) FORCE_SETTINGS=true ;; 47 + --force-secrets) FORCE_SECRETS=true ;; 48 + --force-tunnel) FORCE_TUNNEL=true; FORCE_DNS=true ;; 49 + --force-dns) FORCE_DNS=true ;; 50 + --force-all) FORCE_SETTINGS=true; FORCE_SECRETS=true 51 + FORCE_TUNNEL=true; FORCE_DNS=true ;; 52 + --help|-h) 53 + sed -n '3,/^# ={10}/p' "$0" | sed 's/^# \?//' | sed 's/^#$//' 54 + exit 0 ;; 55 + *) fail "Unknown flag: $arg (try --help)" ;; 56 + esac; done 57 + 58 + # ── Paths & globals ─────────────────────────────────────────────────────────── 59 + 60 + ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" 61 + SETTINGS="$ROOT/settings/config/pds.nix" 62 + SECRETS_FILE="$ROOT/secrets/secrets.nix" 63 + SECRETS_DIR="$ROOT/secrets/age" 64 + AGE_KEY="${AGE_KEY:-$HOME/.config/age/keys.txt}" 65 + CF_DIR="$HOME/.cloudflared" 66 + TUNNEL_NAME="pds" 67 + PLACEHOLDER_UUID="00000000-0000-0000-0000-000000000000" 68 + 69 + RAGENIX=(nix run github:yaxitech/ragenix --) 70 + 71 + # Encrypt a plaintext file ($1) into a .age file ($2), fully hands-off. 72 + # ragenix calls $EDITOR <tmpfile>; we set EDITOR to "cp $src" so it injects 73 + # our content immediately without opening an interactive editor. 74 + ragenix_encrypt() { 75 + local src="$1" target="$2" 76 + rm -f "$target" 77 + EDITOR="cp $src" "${RAGENIX[@]}" \ 78 + --rules "$SECRETS_FILE" \ 79 + --identity "$AGE_KEY" \ 80 + -e "$target" 81 + } 82 + 83 + # Portable in-place sed (handles macOS BSD sed vs GNU sed). 84 + sedi() { if [[ "$(uname -s)" == Darwin ]]; then sed -i '' "$@"; else sed -i "$@"; fi; } 85 + 86 + # Read a value from pds.nix by key name. Works for quoted strings and integers. 87 + read_setting() { 88 + local key="$1" 89 + grep "${key}\s*=" "$SETTINGS" | grep -o '"[^"]*"\|[0-9]\+' | head -1 | tr -d '"' 90 + } 91 + 92 + # ── Step 1: Prerequisites ───────────────────────────────────────────────────── 93 + 94 + log "Step 1/9 — Prerequisites" 95 + 96 + missing=() 97 + for cmd in openssl nix git; do command -v "$cmd" &>/dev/null || missing+=("$cmd"); done 98 + (( ${#missing[@]} == 0 )) || fail "Missing commands: ${missing[*]}" 99 + 100 + if command -v cloudflared &>/dev/null; then 101 + CLOUDFLARED=(cloudflared) 102 + ok "cloudflared: $(command -v cloudflared)" 103 + else 104 + warn "cloudflared not in PATH — using nix run nixpkgs#cloudflared" 105 + CLOUDFLARED=(nix run nixpkgs#cloudflared --) 106 + fi 107 + 108 + [[ -f "$AGE_KEY" ]] || fail "No age key at $AGE_KEY — run secrets/setup.sh first" 109 + ok "age key: $AGE_KEY" 110 + 111 + # ── Step 2: PDS settings ────────────────────────────────────────────────────── 112 + 113 + log "Step 2/9 — PDS settings" 114 + 115 + # Read current values from settings/config/pds.nix 116 + CUR_HOSTNAME=$(read_setting hostname) 117 + CUR_PORT=$(read_setting 'port' | grep -v caddy || true) 118 + # port line is " port = 3000;" — pick the first bare number not on a caddyPort line 119 + CUR_PORT=$(grep -v 'caddy\|Caddy' "$SETTINGS" \ 120 + | grep 'port\s*=' | grep -o '[0-9]\+' | head -1) 121 + CUR_EMAIL=$(read_setting adminEmail) 122 + 123 + if ! $FORCE_SETTINGS; then 124 + skip "Using existing settings (hostname=$CUR_HOSTNAME port=$CUR_PORT email=$CUR_EMAIL)" 125 + else 126 + section "Leave a field blank to keep the current value shown in [brackets]." 127 + echo 128 + 129 + read -rp " PDS hostname [${CUR_HOSTNAME}]: " NEW_HOSTNAME 130 + read -rp " PDS port [${CUR_PORT}]: " NEW_PORT 131 + read -rp " Admin email [${CUR_EMAIL}]: " NEW_EMAIL 132 + 133 + NEW_HOSTNAME="${NEW_HOSTNAME:-$CUR_HOSTNAME}" 134 + NEW_PORT="${NEW_PORT:-$CUR_PORT}" 135 + NEW_EMAIL="${NEW_EMAIL:-$CUR_EMAIL}" 136 + 137 + # Validate port is numeric 138 + [[ "$NEW_PORT" =~ ^[0-9]+$ ]] || fail "Port must be a number, got: $NEW_PORT" 139 + 140 + # Patch settings/config/pds.nix 141 + sedi "s|hostname\s*=\s*\"[^\"]*\"|hostname = \"${NEW_HOSTNAME}\"|" "$SETTINGS" 142 + sedi "s|adminEmail\s*=\s*\"[^\"]*\"|adminEmail = \"${NEW_EMAIL}\"|" "$SETTINGS" 143 + # Port is a bare integer; match the specific line (not caddyPort) 144 + sedi "/caddyPort/! s|\(port\s*=\s*\)[0-9]\+|\1${NEW_PORT}|" "$SETTINGS" 145 + 146 + CUR_HOSTNAME="$NEW_HOSTNAME" 147 + CUR_PORT="$NEW_PORT" 148 + CUR_EMAIL="$NEW_EMAIL" 149 + 150 + ok "Patched: hostname=$CUR_HOSTNAME port=$CUR_PORT email=$CUR_EMAIL" 151 + fi 152 + 153 + # ── SMTP (optional; read from env or prompt once) ───────────────────────────── 154 + 155 + SMTP_URL="${PDS_EMAIL_SMTP_URL:-}" 156 + SMTP_FROM="${PDS_EMAIL_FROM_ADDRESS:-}" 157 + 158 + if [[ -z "$SMTP_URL" ]]; then 159 + echo 160 + warn "SMTP is optional but required for password resets and email verification." 161 + warn "Easiest: Resend (https://resend.com) — free tier is plenty for a personal PDS." 162 + warn "Skip with Enter, or pre-set via env to suppress this prompt entirely:" 163 + warn " PDS_EMAIL_SMTP_URL=... PDS_EMAIL_FROM_ADDRESS=... bash $0" 164 + echo 165 + read -rp " SMTP URL (blank to skip): " SMTP_URL 166 + [[ -n "$SMTP_URL" ]] && read -rp " FROM address (blank to skip): " SMTP_FROM 167 + fi 168 + 169 + [[ -n "$SMTP_URL" ]] \ 170 + && ok "SMTP: $SMTP_FROM via $SMTP_URL" \ 171 + || warn "SMTP skipped" 172 + 173 + # ── Step 3: PDS runtime secrets ─────────────────────────────────────────────── 174 + 175 + log "Step 3/9 — PDS runtime secrets (pds.env.age)" 176 + 177 + PDS_ENV_AGE="$SECRETS_DIR/pds.env.age" 178 + IS_REAL_AGE=false 179 + [[ -f "$PDS_ENV_AGE" ]] && head -1 "$PDS_ENV_AGE" | grep -q "^age-encryption.org" \ 180 + && IS_REAL_AGE=true 181 + 182 + if $IS_REAL_AGE && ! $FORCE_SECRETS; then 183 + skip "pds.env.age" 184 + else 185 + $FORCE_SECRETS && $IS_REAL_AGE && warn "Regenerating secrets as requested (--force-secrets)" 186 + ! $IS_REAL_AGE && [[ -f "$PDS_ENV_AGE" ]] \ 187 + && warn "pds.env.age exists but is not valid age ciphertext — replacing" 188 + 189 + JWT_SECRET=$(openssl rand --hex 16) 190 + ADMIN_PASSWORD=$(openssl rand --hex 16) 191 + ROTATION_KEY=$(openssl ecparam --name secp256k1 --genkey --noout --outform DER \ 192 + | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32) 193 + 194 + TMPENV=$(mktemp); chmod 600 "$TMPENV" 195 + trap 'rm -f "$TMPENV"' EXIT 196 + 197 + { 198 + echo "PDS_JWT_SECRET=${JWT_SECRET}" 199 + echo "PDS_ADMIN_PASSWORD=${ADMIN_PASSWORD}" 200 + echo "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${ROTATION_KEY}" 201 + [[ -n "$SMTP_URL" ]] && echo "PDS_EMAIL_SMTP_URL=${SMTP_URL}" 202 + [[ -n "$SMTP_FROM" ]] && echo "PDS_EMAIL_FROM_ADDRESS=${SMTP_FROM}" 203 + } > "$TMPENV" 204 + 205 + ragenix_encrypt "$TMPENV" "$PDS_ENV_AGE" 206 + rm -f "$TMPENV"; trap - EXIT 207 + 208 + ok "pds.env.age encrypted" 209 + echo 210 + echo " ${BOLD}Save these — they cannot be recovered from the encrypted file:${RESET}" 211 + printf " %-52s %s\n" "PDS_JWT_SECRET:" "$JWT_SECRET" 212 + printf " %-52s %s\n" "PDS_ADMIN_PASSWORD:" "$ADMIN_PASSWORD" 213 + printf " %-52s\n" "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX:" 214 + printf " %s\n" "$ROTATION_KEY" 215 + fi 216 + 217 + # ── Step 4: Cloudflare authentication ───────────────────────────────────────── 218 + 219 + log "Step 4/9 — Cloudflare authentication" 220 + 221 + if [[ -f "$CF_DIR/cert.pem" ]] && ! $FORCE_TUNNEL; then 222 + ok "Already authenticated ($CF_DIR/cert.pem exists)" 223 + else 224 + $FORCE_TUNNEL && warn "Re-authenticating (--force-tunnel)" 225 + warn "A browser window will open — this only happens once." 226 + "${CLOUDFLARED[@]}" tunnel login 227 + ok "Authenticated" 228 + fi 229 + 230 + # ── Step 5: Cloudflare tunnel ───────────────────────────────────────────────── 231 + 232 + log "Step 5/9 — Cloudflare tunnel" 233 + 234 + TUNNEL_UUID="" 235 + 236 + # Check for an existing tunnel with this name 237 + EXISTING_UUID=$("${CLOUDFLARED[@]}" tunnel list 2>/dev/null \ 238 + | awk -v n="$TUNNEL_NAME" '$2 == n {print $1; exit}' || true) 239 + 240 + if [[ -n "$EXISTING_UUID" ]] && $FORCE_TUNNEL; then 241 + warn "Deleting existing tunnel $EXISTING_UUID (--force-tunnel)" 242 + "${CLOUDFLARED[@]}" tunnel delete --force "$EXISTING_UUID" 243 + EXISTING_UUID="" 244 + fi 245 + 246 + if [[ -n "$EXISTING_UUID" ]]; then 247 + TUNNEL_UUID="$EXISTING_UUID" 248 + ok "Using existing tunnel: $TUNNEL_UUID" 249 + else 250 + CREATE_OUTPUT=$("${CLOUDFLARED[@]}" tunnel create "$TUNNEL_NAME" 2>&1) 251 + echo "$CREATE_OUTPUT" 252 + TUNNEL_UUID=$(echo "$CREATE_OUTPUT" \ 253 + | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' \ 254 + | head -1) 255 + [[ -n "$TUNNEL_UUID" ]] || fail "Could not extract UUID from cloudflared output" 256 + ok "Tunnel created: $TUNNEL_UUID" 257 + fi 258 + 259 + TUNNEL_CREDS="$CF_DIR/$TUNNEL_UUID.json" 260 + [[ -f "$TUNNEL_CREDS" ]] || fail "Credentials file missing: $TUNNEL_CREDS" 261 + 262 + # ── Step 6: Patch tunnelId into pds.nix ─────────────────────────────────────── 263 + 264 + log "Step 6/9 — Patch tunnelId in settings/config/pds.nix" 265 + 266 + CURRENT_UUID=$(read_setting tunnelId) 267 + 268 + if [[ "$CURRENT_UUID" == "$TUNNEL_UUID" ]]; then 269 + ok "tunnelId already correct" 270 + else 271 + sedi "s|tunnelId = \"${CURRENT_UUID}\"|tunnelId = \"${TUNNEL_UUID}\"|" "$SETTINGS" 272 + ok "Patched: $CURRENT_UUID → $TUNNEL_UUID" 273 + fi 274 + 275 + # ── Step 7: Encrypt tunnel credentials ─────────────────────────────────────── 276 + 277 + log "Step 7/9 — Encrypt tunnel credentials (cf-tunnel-pds.json.age)" 278 + 279 + CF_AGE="$SECRETS_DIR/cf-tunnel-pds.json.age" 280 + IS_REAL_CF_AGE=false 281 + [[ -f "$CF_AGE" ]] && head -1 "$CF_AGE" | grep -q "^age-encryption.org" \ 282 + && IS_REAL_CF_AGE=true 283 + 284 + if $IS_REAL_CF_AGE && ! $FORCE_DNS; then 285 + skip "cf-tunnel-pds.json.age" 286 + else 287 + $FORCE_DNS && $IS_REAL_CF_AGE && warn "Re-encrypting credentials (--force-dns)" 288 + ! $IS_REAL_CF_AGE && [[ -f "$CF_AGE" ]] \ 289 + && warn "cf-tunnel-pds.json.age is a placeholder — replacing" 290 + ragenix_encrypt "$TUNNEL_CREDS" "$CF_AGE" 291 + ok "cf-tunnel-pds.json.age encrypted" 292 + fi 293 + 294 + # Remove plaintext credentials now they're encrypted in the repo. 295 + rm -f "$TUNNEL_CREDS" 296 + ok "Removed plaintext $TUNNEL_CREDS" 297 + 298 + # ── Step 8: DNS ─────────────────────────────────────────────────────────────── 299 + 300 + log "Step 8/9 — DNS records" 301 + 302 + HANDLE_DOMAINS=$(grep -A10 'serviceHandleDomains' "$SETTINGS" \ 303 + | grep -B10 '];' | head -n-1 \ 304 + | grep -o '"[^"]*"' | tr -d '"' | sed 's/^\.//' || true) 305 + 306 + echo 307 + echo " Add these CNAMEs in Cloudflare DNS (Proxied ✓):" 308 + echo 309 + printf " ${BOLD}%-45s %s${RESET}\n" "Name" "Target" 310 + printf " %-45s %s\n" "$CUR_HOSTNAME" "${TUNNEL_UUID}.cfargotunnel.com" 311 + for domain in $HANDLE_DOMAINS; do 312 + printf " %-45s %s\n" "*.${domain}" "${TUNNEL_UUID}.cfargotunnel.com" 313 + done 314 + echo 315 + echo " Or add automatically via the API:" 316 + echo " ${CLOUDFLARED[*]} tunnel route dns $TUNNEL_NAME $CUR_HOSTNAME" 317 + for domain in $HANDLE_DOMAINS; do 318 + echo " ${CLOUDFLARED[*]} tunnel route dns $TUNNEL_NAME '*.${domain}'" 319 + done 320 + 321 + # ── Step 9: Rekey ───────────────────────────────────────────────────────────── 322 + 323 + log "Step 9/9 — Rekey secrets" 324 + 325 + AGE_COUNT=$(find "$SECRETS_DIR" -name "*.age" \ 326 + -exec grep -l "^age-encryption.org" {} \; 2>/dev/null | wc -l | tr -d ' ') 327 + 328 + if (( AGE_COUNT > 0 )); then 329 + "${RAGENIX[@]}" --rules "$SECRETS_FILE" --identity "$AGE_KEY" -r 330 + ok "Rekeyed $AGE_COUNT secrets" 331 + else 332 + warn "No encrypted .age files to rekey" 333 + fi 334 + 335 + # ── Done ────────────────────────────────────────────────────────────────────── 336 + 337 + echo 338 + echo "${BOLD}${GREEN}══════════════════════════════════════════════${RESET}" 339 + echo "${BOLD}${GREEN} Done.${RESET}" 340 + echo "${BOLD}${GREEN}══════════════════════════════════════════════${RESET}" 341 + echo 342 + echo " ${CYAN}git add settings/config/pds.nix secrets/${RESET}" 343 + echo " ${CYAN}git commit -m 'pds: pre-deploy setup (tunnel ${TUNNEL_UUID})'${RESET}" 344 + echo " ${CYAN}git push${RESET}" 345 + echo 346 + echo " ${BOLD}Still needed on deploy day:${RESET}" 347 + echo " 1. Generate hardware config → hosts/server/minimal-hardware.nix" 348 + echo " 2. Get server age key:" 349 + echo " ${CYAN}nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age'${RESET}" 350 + echo " 3. Add to secrets/secrets.nix → systems.server, update pdsKeys" 351 + echo " 4. Rekey: ${CYAN}nix run github:yaxitech/ragenix -- --rules secrets/secrets.nix -r${RESET}" 352 + echo " 5. ${CYAN}nixos-install --flake .#server && reboot${RESET}" 353 + echo " 6. ${CYAN}curl https://${CUR_HOSTNAME}/xrpc/_health${RESET}" 354 + echo
+13
secrets/age/cf-tunnel-pds.json.age
··· 1 + -----BEGIN AGE ENCRYPTED FILE----- 2 + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPYXF3N1B3cFVoUHhZZjFN 3 + TmdPcFdlQ3NHa3RsQks3UTlqeC81QUJHNGtRCkQ2UnlhRWF3dkxaWjNOS0FFeEdU 4 + SzVvWGcvSHdER1JzV1pwWUFyalY0eGMKLT4gTztxU0B4SSotZ3JlYXNlIDp1Sjgj 5 + IH1FYDorIVozClg2Y0U1bnluZDNQU0xyeHhxMUJpNGJyZUhUQ2JvcCtqSDRZYW9m 6 + TStwd0k2ZEUxTjlxbkJvTzJ5c1c4RDBTZlcKT1g4WnhTZFZJamdNbmJVOWVMMFBr 7 + R3FFek5GQk8rWXZJaU5RdTVNCi0tLSBJNTFDM2F1czJacDlER2dDdG1ESnl6Q0xu 8 + UDROL1MzcEJGSm9mazVBTGxFCpBLkSqqkePrBskWlsgiar2MOpUHCAIqgvZEi/px 9 + b9kOtFwFPdWiSN88ban5Zq1ACOJQp0rBIVNVQKAt8T6qoie2idY/Cgbu6BlObGD6 10 + 54bRSP+Fz+FO7UW+1l7iZtC3X75luB3oxdg+5c0nMFBB66oarfxZXlcK1eV1w1Oo 11 + WqyRiuAuA12C0ngbiRydRXS28RM/kY18+1XOmqDWXKTuy5X8Y20ylcf+X+3hOL+m 12 + PsHNOeFfmfG5ufpqDFciQl7tGuyovwBjSepdh98zeAYtTg== 13 + -----END AGE ENCRYPTED FILE-----
+13 -16
secrets/age/pds.env.age
··· 1 1 -----BEGIN AGE ENCRYPTED FILE----- 2 - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIR3hnTW4zd0tsVjNFVzFG 3 - c2xpbzV4S2EyTWN5NFRNYnhkcHd6V212MFFFCmNCNEdwbnBpV3ROUDdzMWphaGI5 4 - RGEvZmp0SVh5anFXZHVSK0xaVktDRVEKLT4gWDI1NTE5IDFaaEF3VGhSRU1LQkRy 5 - ZTRtcXhMa05yVWdwRlliUXF6U3R1bzJ4NU0wRzgKQi81K0Y4eDBGMmtaVkw3WGZZ 6 - di9ITTVjK2JYOEg4WHd4WHVEMmsxTm9EYwotPiBYMjU1MTkgWlp3N296aHhGazdD 7 - MVMyZnQ0WXh0K0FiaXNVOXpURm8yeGFkNExXUXJnbwprZFUzR3JRemhRQURHUXc4 8 - d2Vzck1oYWpQbGtNa2RSd0tWZllNb2I1WHNBCi0+IEo9VkItZ3JlYXNlICsiRSBr 9 - XTMuIFg/WHdiXwpjTDZNVVFNMUZaN3pMYTd2VmZJVExYU3dmUFppbTl3L3hnZUFj 10 - R2hvNGJvY0lHdVNVZkVtbVc0cXZXdmFUcHh2CmdnQ0Z3MjIvRnR4c2NCbDQ4ODNG 11 - eG9RUE9nVmo5UFBPMWp4aDR1OWJ2SVlNVnVHVjZmbkJNWTZNZDV3Ci0tLSBEUGdG 12 - Z1U1ODByUXcza29pS0dFYWxac0lDUzJMaFB5SVpVWFBpRVpUL3pBCj08rbuvJ1Ie 13 - sAvXWQM+9iYdQefJnOuAdjNxCQgL4+UiRVVxa6cmSTNwg0rqC/ZgX2SO/Wemn6F5 14 - GPU5gUJGrzz9q7KAtTdD5h/WlQ2iq29UGEqRvLV7uGO93cYGppgzN/ALZikXJKgN 15 - DbmNpsHhP03zS6H0WvUx4CEr1Re/UBJ6H/ly17uE0R/hViMe77ez5iFXH3qqk/mw 16 - QFsDXz/ykmy79A0122jOHTqemn8nI3B5cya1ABqoftxpjbzAHmveU264TUI1Wj0m 17 - QJHKLNkCBaNmag4ere6FbsnbObKHEXpxoC9hx7GvSrIAbaLfd162T1dqouY= 2 + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUWEx3V3MrQkkrWFM2ZlA1 3 + QUk5VHo1ckpUbU1SbUEramhLNVRhY1drS21VCkhCTjNsemh2S0NjY0lQZEdkMy9N 4 + cHJsWDFDbzdJZUhXcWw4Q3hEcnczMzQKLT4gezEtZ3JlYXNlID9hICErCjh2Y2VV 5 + c0JHZEtEcitCRTNVc1VRCi0tLSBxdHNPYmYvUmR1ekpKY0x4ZXRwbHJSRGkzR25w 6 + bEdZUnRNdk14blNpcDhnCklUXXmNPbMeKsjEku263zj/HZgqmYSMsqvHP+WeV9yV 7 + jMXnx7BoWdlsTSTiDQk5qb6Ns8kcqH074/dc4S5wS+FrMubwLuGjmZUOEyEtpGK+ 8 + tGn88cIX5i5z1vWmWiNpPu78e/z3skqm079TLg70mrDoKWHCJoD+/f+lggLdLy6M 9 + urr6t+BGR0T54JppDYMveeU1ZZ8pOsAKPEqb5K1A/PhgWtx6M7mLVbo3fhQES+SC 10 + dcMrCsokXROKVxsoL3I4ncgZbR0PElWZehbS3rrXaPxPSatob0W3A9Z38GwaTYGf 11 + VPPsjwaOsKPTpRsBq4foWM4kZ0ZrITCgeJfCStWRbMiGmF6zrqZVIKRqLz4FVbBv 12 + xiB5zYTT5bI0vchMA2lU0SGq/RGwhJ5hXhrduufWogjh2D10EggB96nLpJwfPq6p 13 + dhYCRmH7kMhNM6363mlgZcNhUUFtyE/opxAk/J7CjJoQe8MJrH90TtGhvjMMU2Lf 14 + GUVsNIVwdKH2sA== 18 15 -----END AGE ENCRYPTED FILE-----
+20 -8
secrets/secrets.nix
··· 5 5 6 6 systems = { 7 7 macmini = "age10ysmz3603uupz0043mpznchtnh6jsnk5cu3eg05xalma4xjacppsgupgvj"; 8 - laptop = "age1s4exn5venvd2rkrvw9g6g9rua05quut62m6le8k79st0dryhcy3qq4n55k"; 8 + laptop = "age1s4exn5venvd2rkrvw9g6g9rua05quut62m6le8k79st0dryhcy3qq4n55k"; 9 + # Add the server key once the host exists: 10 + # nix-shell -p ssh-to-age --run 'ssh-keyscan <server-ip> | ssh-to-age' 11 + # Then uncomment and paste the result here, and run: 12 + # nix run github:yaxitech/ragenix -- --rules secrets/secrets.nix --rekey 13 + # server = "age1..."; 9 14 }; 10 15 11 16 all = (builtins.attrValues users) ++ (builtins.attrValues systems); 17 + 18 + # Until the server key is added above, PDS secrets are encrypted for ewan 19 + # only (so rekeying works from the macmini/laptop today). 20 + # After adding the server key, change this to: [ users.ewan systems.server ] 21 + pdsKeys = [ users.ewan ]; 12 22 in 13 23 { 14 - # Add your actual secrets here 15 - # UI preferences are NOT secrets and should not be encrypted 24 + # Network credentials 25 + "age/wifi-home.age".publicKeys = all; 16 26 17 - # Network Credentials (REAL secrets) 18 - "age/wifi-home.age".publicKeys = all; 19 - 20 - # SSH Key Passphrases 27 + # SSH key passphrases 21 28 "age/ssh-passphrase.age".publicKeys = all; 22 - "pds.env.age".publicKeys = all; 29 + 30 + # PDS runtime secrets (KEY=value env file) 31 + "age/pds.env.age".publicKeys = pdsKeys; 32 + 33 + # Cloudflare tunnel JSON credentials file (from `cloudflared tunnel create pds`) 34 + "age/cf-tunnel-pds.json.age".publicKeys = pdsKeys; 23 35 }
+44 -14
settings/config/pds.nix
··· 1 - { config, pkgs, ... }: 1 + { 2 + # Bluesky ATProto Personal Data Server configuration. 3 + # Non-secret settings only. Secrets (PDS_JWT_SECRET, PDS_ADMIN_PASSWORD, 4 + # PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX, PDS_EMAIL_SMTP_URL, 5 + # PDS_EMAIL_FROM_ADDRESS) live in secrets/age/pds.env.age. 6 + 7 + enable = true; 2 8 3 - { 4 - services.pds = { 5 - enable = true; 6 - environmentFile = config.age.secrets."pds.env.age".path; 7 - }; 9 + # Public hostname — also used as the Caddy virtual host and the Cloudflare 10 + # tunnel public hostname. Subdomains are used for account handles. 11 + hostname = "pds.ewancroft.uk"; 8 12 9 - networking.firewall.allowedTCPPorts = [ 3000 80 443 ]; 13 + # Internal port the PDS process listens on. Never exposed publicly. 14 + port = 3000; 10 15 11 - systemd.services.pds.serviceConfig = { 12 - Restart = "always"; 13 - RestartSec = 5; 14 - }; 16 + # Email shown in the PDS admin panel. 17 + adminEmail = "pds@ewancroft.uk"; 15 18 16 - systemd.services.pds.unitConfig = { 17 - StartLimitIntervalSec = 300; 18 - StartLimitBurst = 5; 19 + # Additional handle domains. ".ewancroft.uk" lets users have @user.ewancroft.uk handles. 20 + serviceHandleDomains = [ ".ewancroft.uk" ]; 21 + 22 + # ATProto relay crawlers — sourced from https://compare.hose.cam 23 + crawlers = [ 24 + "https://bsky.network" 25 + "https://relay.cerulea.blue" 26 + "https://relay.fire.hose.cam" 27 + "https://relay2.fire.hose.cam" 28 + "https://relay3.fr.hose.cam" 29 + "https://relay.hayescmd.net" 30 + "https://relay.xero.systems" 31 + "https://relay.upcloud.world" 32 + "https://relay.feeds.blue" 33 + "https://atproto.africa" 34 + ]; 35 + 36 + # Caddy internal listen port — Cloudflare tunnel routes here. 37 + caddyPort = 2020; 38 + 39 + # Cloudflare tunnel settings. 40 + # tunnelId: UUID from `cloudflared tunnel create pds` (shown in the dashboard). 41 + # Replace this placeholder after running that command. 42 + cloudflare = { 43 + tunnelId = "5d78eb68-af85-4c13-b28d-907bb570c259"; 19 44 }; 45 + 46 + # systemd restart policy 47 + restartSec = 5; 48 + startLimitIntervalSec = 300; 49 + startLimitBurst = 5; 20 50 }