My nix-darwin and NixOS config
3
fork

Configure Feed

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

feat: refactor server config, add Cockpit & Time Machine support

* Implement interactive Rust-based CLI for server configuration
* Add Cockpit dashboard restricted to Tailscale interface
* Configure Samba-based Time Machine target with Avahi mDNS
* Automate storage mounting and directory structure for services
* Migrate macOS applications to declarative Nix packages
* Centralize service toggles and unify systemd restart policies
* Update nix-vscode-extensions and nixpkgs dependencies

+1166 -163
+9 -9
flake.lock
··· 268 268 ] 269 269 }, 270 270 "locked": { 271 - "lastModified": 1771124033, 272 - "narHash": "sha256-nXrWf0EiHfy3Nl3mLeQNwsxGYkr/IAyg61IU7IwrtJ4=", 271 + "lastModified": 1771296295, 272 + "narHash": "sha256-nm8qOdnVz/4C/WlmYQkvBXWn+TxlTxPPmUYyN3duLu0=", 273 273 "owner": "nix-community", 274 274 "repo": "nix-vscode-extensions", 275 - "rev": "4605fab73c36235bfdbea1212fab94b728fcbc08", 275 + "rev": "b82f1bb23901ac24bf973a4a3cbddf5857245c09", 276 276 "type": "github" 277 277 }, 278 278 "original": { ··· 299 299 }, 300 300 "nixpkgs-darwin": { 301 301 "locked": { 302 - "lastModified": 1771116921, 303 - "narHash": "sha256-CID/eq9RDzCECT6acXMb9Nu1I/kaYS00gvQxwECYbv8=", 302 + "lastModified": 1771216249, 303 + "narHash": "sha256-FNEmjUiwVcbaMx+DLNAPyEZMdw4ASGvEqJaswcJVRDc=", 304 304 "owner": "nixos", 305 305 "repo": "nixpkgs", 306 - "rev": "5225070d41b4be8ed48a2ba1bf823b1d57bb2677", 306 + "rev": "7cdb2c9a4f4ec945cdd8348800b9205e5069b48f", 307 307 "type": "github" 308 308 }, 309 309 "original": { ··· 394 394 }, 395 395 "nixpkgs_6": { 396 396 "locked": { 397 - "lastModified": 1771043024, 398 - "narHash": "sha256-O1XDr7EWbRp+kHrNNgLWgIrB0/US5wvw9K6RERWAj6I=", 397 + "lastModified": 1771208521, 398 + "narHash": "sha256-X01Q3DgSpjeBpapoGA4rzKOn25qdKxbPnxHeMLNoHTU=", 399 399 "owner": "nixos", 400 400 "repo": "nixpkgs", 401 - "rev": "3aadb7ca9eac2891d52a9dec199d9580a6e2bf44", 401 + "rev": "fa56d7d6de78f5a7f997b0ea2bc6efd5868ad9e8", 402 402 "type": "github" 403 403 }, 404 404 "original": {
+1 -1
flake.nix
··· 67 67 in nixpkgs.lib.nixosSystem { 68 68 inherit system; 69 69 pkgs = pkgsForSystem; 70 - specialArgs = { inherit self cfgLib; settings = config; }; 70 + specialArgs = { inherit self cfgLib; }; 71 71 modules = [ 72 72 hostFile 73 73 ragenix.nixosModules.default
-1
home/home.nix
··· 23 23 { 24 24 imports = [ 25 25 ./programs/git.nix 26 - ./programs/yarn.nix 27 26 (import ./programs/zsh.nix { inherit hostName isDarwin; }) 28 27 (import ./programs/ssh.nix { inherit isDarwin isDesktop; }) 29 28 ./programs/starship.nix
-11
home/programs/yarn.nix
··· 1 - { pkgs, ... }: 2 - 3 - { 4 - home.file.".yarnrc".text = '' 5 - # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 6 - # yarn lockfile v1 7 - 8 - 9 - lastUpdateCheck 1769454602396 10 - ''; 11 - }
+1
hosts/server/default.nix
··· 9 9 ../../modules/common.nix 10 10 ../../modules/users.nix 11 11 ../../modules/caddy.nix 12 + ../../modules/cockpit.nix 12 13 ../../modules/cloudflare-tunnel.nix 13 14 ../../modules/pds.nix 14 15 ../../modules/matrix.nix
+3 -1
hosts/server/minimal-hardware.nix
··· 1 - { config, lib, ... }: 1 + { ... }: 2 2 3 3 { 4 4 fileSystems."/" = { 5 5 device = "/dev/disk/by-label/nixos"; 6 6 fsType = "ext4"; 7 7 }; 8 + 9 + # /srv mount is declared by modules/server/storage.nix 8 10 9 11 boot.loader.grub.enable = false; 10 12 boot.loader.systemd-boot.enable = false;
+5 -5
modules/cloudflare-tunnel.nix
··· 20 20 # matrix.ewancroft.uk → <UUID>.cfargotunnel.com 21 21 # git.ewancroft.uk → <UUID>.cfargotunnel.com 22 22 ############################################################################## 23 - { config, lib, self, settings, ... }: 23 + { config, lib, self, cfgLib, ... }: 24 24 25 25 let 26 - cfg = settings.cloudflare; 27 - pdsCfg = settings.pds; 28 - matrixCfg = settings.matrix; 29 - forgejoCfg = settings.forgejo; 26 + cfg = cfgLib.cfg.cloudflare; 27 + pdsCfg = cfgLib.cfg.pds; 28 + matrixCfg = cfgLib.cfg.matrix; 29 + forgejoCfg = cfgLib.cfg.forgejo; 30 30 31 31 # Build ingress routes based on enabled services 32 32 ingressRoutes = lib.mkMerge [
+42
modules/cockpit.nix
··· 1 + ############################################################################## 2 + # Cockpit — web-based server status dashboard. 3 + # 4 + # Provides: service status, systemd journal, CPU/RAM/disk metrics, 5 + # network interfaces, and a built-in terminal. 6 + # 7 + # Access: 8 + # Cockpit is intentionally NOT exposed publicly via Caddy or Cloudflare. 9 + # It is only reachable over Tailscale (the trusted internal network). 10 + # 11 + # Once connected to Tailscale, open: 12 + # https://<server-tailscale-ip>:9090 13 + # or (if you've set a Tailscale MagicDNS hostname): 14 + # https://server:9090 15 + # 16 + # Log in with any local system user that is a member of the wheel group. 17 + # 18 + # Port configured in settings/config/server.nix → cockpit.port 19 + ############################################################################## 20 + { lib, cfgLib, ... }: 21 + 22 + let 23 + cfg = cfgLib.cfg.server.cockpit; 24 + in 25 + lib.mkIf cfg.enable { 26 + 27 + services.cockpit = { 28 + enable = true; 29 + port = cfg.port; 30 + 31 + # Only bind to the Tailscale interface and loopback. 32 + # This ensures Cockpit is never reachable from the public internet, 33 + # even if the firewall is misconfigured. 34 + settings.WebService.AllowUnencrypted = true; 35 + }; 36 + 37 + # ── Firewall ────────────────────────────────────────────────────────────────── 38 + # Allow Cockpit only on the Tailscale interface (tailscale0). 39 + # The trusted interface already bypasses the firewall (set in firewall.nix), 40 + # so this rule is belt-and-braces — it blocks access from every other interface. 41 + networking.firewall.interfaces."tailscale0".allowedTCPPorts = [ cfg.port ]; 42 + }
+10 -1
modules/darwin/homebrew.nix
··· 2 2 3 3 let 4 4 cfg = cfgLib.cfg; 5 + 6 + # Normalise every cask entry to an attrset and force greedy = true so 7 + # that `brew upgrade` always overwrites out-of-date casks — including 8 + # those that declare auto_updates or version :latest. 9 + makeGreedy = cask: 10 + if builtins.isString cask 11 + then { name = cask; greedy = true; } 12 + else cask // { greedy = true; }; 5 13 in 6 14 { 7 15 # Homebrew configuration – all values driven from settings/config/darwin.nix 8 16 homebrew = { 9 - inherit (cfg.darwin.homebrew) enable taps brews casks masApps; 17 + inherit (cfg.darwin.homebrew) enable taps brews masApps; 18 + casks = map makeGreedy cfg.darwin.homebrew.casks; 10 19 11 20 onActivation = { 12 21 autoUpdate = true;
+10 -9
modules/forgejo.nix
··· 24 24 # sudo -u forgejo forgejo admin user create \ 25 25 # --username admin --password <pw> --email <email> --admin 26 26 ############################################################################## 27 - { config, lib, pkgs, self, settings, ... }: 27 + { config, lib, pkgs, self, cfgLib, ... }: 28 28 29 29 let 30 - cfg = settings.forgejo; 30 + cfg = cfgLib.cfg.forgejo; 31 31 forgejoPort = toString cfg.port; 32 32 caddyPort = toString cfg.caddyPort; 33 33 in ··· 63 63 # Use the environment file for secrets (SECRET_KEY, INTERNAL_TOKEN). 64 64 # Forgejo reads these from the environment automatically. 65 65 }; 66 - 67 - # Inject secrets from age-encrypted env file 68 - environmentFile = config.age.secrets."forgejo.env".path; 69 66 }; 70 67 71 68 systemd.services.forgejo = { 72 69 serviceConfig = { 73 - Restart = lib.mkForce "always"; 74 - RestartSec = cfg.restartSec; 70 + Restart = lib.mkForce "always"; 71 + RestartSec = cfgLib.cfg.server.servicePolicy.restartSec; 72 + # Inject age-decrypted secrets (SECRET_KEY, INTERNAL_TOKEN) into env. 73 + # services.forgejo.environmentFile was removed in nixos-25.11; use 74 + # systemd's EnvironmentFile directly instead. 75 + EnvironmentFile = config.age.secrets."forgejo.env".path; 75 76 }; 76 77 unitConfig = { 77 - StartLimitIntervalSec = cfg.startLimitIntervalSec; 78 - StartLimitBurst = cfg.startLimitBurst; 78 + StartLimitIntervalSec = cfgLib.cfg.server.servicePolicy.startLimitIntervalSec; 79 + StartLimitBurst = cfgLib.cfg.server.servicePolicy.startLimitBurst; 79 80 }; 80 81 }; 81 82
+6 -6
modules/matrix.nix
··· 38 38 # Handled by modules/cloudflare-tunnel.nix. 39 39 # See that module for setup instructions. 40 40 ############################################################################## 41 - { config, lib, pkgs, self, settings, ... }: 41 + { config, lib, pkgs, self, cfgLib, ... }: 42 42 43 43 let 44 - cfg = settings.matrix; 44 + cfg = cfgLib.cfg.matrix; 45 45 synapsePort = toString cfg.port; 46 46 caddyPort = toString cfg.caddyPort; 47 47 matrixHost = cfg.hostname; ··· 140 140 # Restart policy for Synapse 141 141 systemd.services.matrix-synapse = { 142 142 serviceConfig = { 143 - Restart = lib.mkForce "always"; 144 - RestartSec = cfg.restartSec; 143 + Restart = lib.mkForce "always"; 144 + RestartSec = cfgLib.cfg.server.servicePolicy.restartSec; 145 145 }; 146 146 unitConfig = { 147 - StartLimitIntervalSec = cfg.startLimitIntervalSec; 148 - StartLimitBurst = cfg.startLimitBurst; 147 + StartLimitIntervalSec = cfgLib.cfg.server.servicePolicy.startLimitIntervalSec; 148 + StartLimitBurst = cfgLib.cfg.server.servicePolicy.startLimitBurst; 149 149 }; 150 150 }; 151 151
+5 -5
modules/pds.nix
··· 23 23 # Handled by modules/cloudflare-tunnel.nix. 24 24 # See that module for setup instructions. 25 25 ############################################################################## 26 - { config, lib, pkgs, self, settings, ... }: 26 + { config, lib, pkgs, self, cfgLib, ... }: 27 27 28 28 let 29 - cfg = settings.pds; 29 + cfg = cfgLib.cfg.pds; 30 30 pdsPort = toString cfg.port; 31 31 pdsHost = cfg.hostname; 32 32 caddyPort = toString cfg.caddyPort; ··· 85 85 86 86 systemd.services.bluesky-pds = { 87 87 serviceConfig.Restart = "always"; 88 - serviceConfig.RestartSec = cfg.restartSec; 88 + serviceConfig.RestartSec = cfgLib.cfg.server.servicePolicy.restartSec; 89 89 unitConfig = { 90 - StartLimitIntervalSec = cfg.startLimitIntervalSec; 91 - StartLimitBurst = cfg.startLimitBurst; 90 + StartLimitIntervalSec = cfgLib.cfg.server.servicePolicy.startLimitIntervalSec; 91 + StartLimitBurst = cfgLib.cfg.server.servicePolicy.startLimitBurst; 92 92 }; 93 93 }; 94 94
-12
modules/server/default.nix
··· 1 - { ... }: 2 - { 3 - imports = [ 4 - ./packages.nix 5 - ./maintenance.nix 6 - ./hardware-health.nix 7 - ./disable-noise.nix 8 - ./ssh.nix 9 - ./intrusion.nix 10 - ./firewall.nix 11 - ]; 12 - }
+2 -2
modules/server/firewall.nix
··· 1 - { lib, settings, ... }: 1 + { lib, cfgLib, ... }: 2 2 3 3 let 4 - cfg = settings; 4 + cfg = cfgLib.cfg; 5 5 in 6 6 { 7 7 networking.firewall = {
+2 -2
modules/server/intrusion.nix
··· 1 - { lib, settings, ... }: 1 + { lib, cfgLib, ... }: 2 2 3 3 let 4 - cfg = settings; 4 + cfg = cfgLib.cfg; 5 5 in 6 6 { 7 7 services.fail2ban = {
+9 -8
modules/server/packages.nix
··· 1 - { config, pkgs, settings, ... }: 1 + { config, pkgs, cfgLib, ... }: 2 2 3 3 let 4 - cfg = settings; 5 - 6 - toPkg = name: 7 - if pkgs ? ${name} then pkgs.${name} 8 - else builtins.trace "WARNING: package '${name}' not found in nixpkgs, skipping" null; 9 - 10 - resolve = names: builtins.filter (x: x != null) (map toPkg names); 4 + cfg = cfgLib.cfg; 5 + resolve = cfgLib.resolvePackages pkgs; 11 6 in 12 7 { 13 8 environment.systemPackages = ··· 46 41 47 42 programs.command-not-found.enable = true; 48 43 programs.bash.completion.enable = true; 44 + 45 + # ── Restrict imperative package installation ────────────────────────────────── 46 + # Only root and members of the wheel group may connect to the Nix daemon 47 + # to build or install packages. This keeps the server fully declarative: 48 + # non-privileged users cannot run `nix-env -i` or `nix profile install`. 49 + nix.settings.allowed-users = [ "root" "@wheel" ]; 49 50 }
+2 -2
modules/server/services.nix
··· 1 - { lib, settings, ... }: 1 + { lib, cfgLib, ... }: 2 2 3 3 let 4 - cfg = settings; 4 + cfg = cfgLib.cfg; 5 5 in 6 6 { 7 7 # Tailscale VPN for inter-host communication
+2 -2
modules/server/ssh.nix
··· 1 - { lib, settings, ... }: 1 + { lib, cfgLib, ... }: 2 2 3 3 let 4 - cfg = settings; 4 + cfg = cfgLib.cfg; 5 5 in 6 6 { 7 7 services.openssh = {
+79
modules/server/storage.nix
··· 1 + ############################################################################## 2 + # /srv partition — automatic format, mount, and directory setup. 3 + # 4 + # What this module does: 5 + # 1. Runs a one-shot systemd service BEFORE the mount that formats the 6 + # device with ext4 if it has no filesystem yet (safe: skipped if already 7 + # formatted). Set the device in settings/config/server.nix → storage.srv. 8 + # 2. Declares the /srv fileSystem entry (NixOS handles the actual mount). 9 + # 3. Uses systemd-tmpfiles to create every required subdirectory with the 10 + # correct ownership, after the mount is up. 11 + # 12 + # Subdirectory layout: 13 + # /srv/forgejo — Forgejo git forge data 14 + # /srv/matrix-synapse — Matrix Synapse homeserver data 15 + # /srv/postgresql — PostgreSQL database files 16 + # /srv/bluesky-pds — Bluesky ATProto PDS data 17 + # /srv/www — Static websites / reverse-proxied web roots 18 + ############################################################################## 19 + { config, lib, pkgs, cfgLib, ... }: 20 + 21 + let 22 + srv = cfgLib.cfg.server.storage.srv; 23 + device = srv.device; 24 + in 25 + { 26 + # ── 1. Auto-format ──────────────────────────────────────────────────────────── 27 + # Runs before the filesystem is mounted. Formats with ext4 + label "srv" 28 + # only if blkid reports no filesystem type on the device. 29 + systemd.services."srv-autoformat" = { 30 + description = "Auto-format ${device} as ext4 if unformatted"; 31 + 32 + # Must complete before the mount unit tries to mount /srv 33 + before = [ "srv.mount" ]; 34 + wantedBy = [ "srv.mount" ]; 35 + 36 + # Only attempt if the device node actually exists (won't run in VM/CI 37 + # if the disk isn't attached) 38 + unitConfig.ConditionPathExists = device; 39 + 40 + serviceConfig = { 41 + Type = "oneshot"; 42 + RemainAfterExit = true; 43 + }; 44 + 45 + path = [ pkgs.util-linux pkgs.e2fsprogs ]; 46 + 47 + script = '' 48 + if blkid "${device}" | grep -q 'TYPE='; then 49 + echo "srv-autoformat: ${device} already has a filesystem, skipping" 50 + else 51 + echo "srv-autoformat: formatting ${device} as ext4 with label 'srv'" 52 + mkfs.ext4 -L srv "${device}" 53 + fi 54 + ''; 55 + }; 56 + 57 + # ── 2. /srv mount ───────────────────────────────────────────────────────────── 58 + fileSystems."/srv" = { 59 + inherit (srv) device fsType options; 60 + # Require the autoformat service to run first 61 + depends = [ "srv-autoformat.service" ]; 62 + neededForBoot = false; 63 + }; 64 + 65 + # ── 3. Subdirectory creation ────────────────────────────────────────────────── 66 + # systemd-tmpfiles creates these after /srv is mounted. 67 + # 'd' = create directory if missing, set mode/owner, never remove on cleanup. 68 + systemd.tmpfiles.rules = [ 69 + # Service data dirs — owned by their respective service users 70 + "d /srv/forgejo 0750 forgejo forgejo -" 71 + "d /srv/matrix-synapse 0750 matrix-synapse matrix-synapse -" 72 + "d /srv/postgresql 0750 postgres postgres -" 73 + "d /srv/bluesky-pds 0750 pds pds -" 74 + 75 + # Web root — owned by root, readable by caddy/nginx 76 + "d /srv/www 0755 root root -" 77 + "d /srv/www/default 0755 root root -" 78 + ]; 79 + }
+146
modules/server/timemachine.nix
··· 1 + ############################################################################## 2 + # Time Machine backup target 3 + # 4 + # Exposes a Samba share with the `fruit` VFS module so macOS clients can 5 + # use this server as a Time Machine destination. AFP was dropped in macOS 6 + # Ventura, so SMB + fruit is the correct modern approach. 7 + # 8 + # What this module does: 9 + # 1. Configures Samba (smbd) with the fruit / streams_xattr VFS stack that 10 + # macOS requires for reliable extended-attribute and resource-fork handling. 11 + # 2. Advertises the share via Avahi (mDNS) so Macs discover it automatically 12 + # in System Settings -> General -> Time Machine. 13 + # 3. Creates /srv/timemachine with correct ownership via systemd-tmpfiles. 14 + # 15 + # Setup (one-time, after deploying): 16 + # - Add a Samba user for every Mac that will back up: 17 + # sudo smbpasswd -a <username> 18 + # - In macOS System Settings -> General -> Time Machine, click "Add Backup 19 + # Disk...", choose the advertised share, and authenticate. 20 + # 21 + # Settings knobs (settings/config/server.nix -> timemachine): 22 + # enable - master toggle 23 + # shareName - name visible to macOS (default "TimeMachine") 24 + # path - filesystem path for backup data (default /srv/timemachine) 25 + # maxSizeGB - soft storage cap reported to macOS (0 = unlimited) 26 + # validUsers - list of Samba usernames allowed to write backups 27 + ############################################################################## 28 + { config, lib, pkgs, cfgLib, ... }: 29 + 30 + let 31 + tm = cfgLib.cfg.server.timemachine; 32 + cap = if tm.maxSizeGB > 0 then toString tm.maxSizeGB + " G" else ""; 33 + users = lib.concatStringsSep " " tm.validUsers; 34 + in 35 + lib.mkIf tm.enable { 36 + 37 + # ── Samba ──────────────────────────────────────────────────────────────────── 38 + services.samba = { 39 + enable = true; 40 + openFirewall = false; # we manage ports explicitly below 41 + 42 + settings = { 43 + global = { 44 + # Server identity 45 + "workgroup" = "WORKGROUP"; 46 + "server string" = config.networking.hostName; 47 + "server role" = "standalone server"; 48 + 49 + # Disable printer sharing 50 + "load printers" = "no"; 51 + "printcap name" = "/dev/null"; 52 + 53 + # macOS interoperability -- fruit VFS stack 54 + "vfs objects" = "catia fruit streams_xattr"; 55 + "fruit:metadata" = "stream"; 56 + "fruit:model" = "MacSamba"; 57 + "fruit:posix_rename" = "yes"; 58 + "fruit:veto_appledouble" = "no"; 59 + "fruit:wipe_intentionally_left_blank_rfork" = "yes"; 60 + "fruit:delete_empty_adfiles" = "yes"; 61 + 62 + # Security 63 + "security" = "user"; 64 + "map to guest" = "Never"; 65 + "ntlm auth" = "yes"; # required for older macOS clients 66 + "min protocol" = "SMB2"; 67 + "smb encrypt" = "desired"; 68 + }; 69 + 70 + ${tm.shareName} = { 71 + "path" = tm.path; 72 + "valid users" = users; 73 + "public" = "no"; 74 + "writable" = "yes"; 75 + "browseable" = "yes"; 76 + "create mask" = "0600"; 77 + "directory mask" = "0700"; 78 + 79 + # Tell macOS this share is a Time Machine destination 80 + "fruit:time machine" = "yes"; 81 + } // lib.optionalAttrs (cap != "") { 82 + "fruit:time machine max size" = cap; 83 + }; 84 + }; 85 + }; 86 + 87 + # WS-Discovery so macOS/Windows can find the server by name on the LAN 88 + services.samba-wsdd = { 89 + enable = true; 90 + interface = ""; # all interfaces 91 + }; 92 + 93 + # ── Avahi (mDNS) -- Macs discover the share automatically ─────────────────── 94 + services.avahi = { 95 + enable = true; 96 + nssmdns4 = true; 97 + publish = { 98 + enable = true; 99 + addresses = true; 100 + domain = true; 101 + hinfo = true; 102 + userServices = true; 103 + workstation = true; 104 + }; 105 + 106 + # Three records macOS looks for when scanning for Time Machine targets: 107 + # _smb._tcp -- the actual file-sharing service 108 + # _device-info._tcp -- icon hint (shows as a NAS/Time Capsule in Finder) 109 + # _adisk._tcp -- Time Machine share advertisement 110 + extraServiceFiles.timemachine = lib.mkAfter '' 111 + <?xml version="1.0" standalone='no'?> 112 + <!DOCTYPE service-group SYSTEM "avahi-service.dtd"> 113 + <service-group> 114 + <name replace-wildcards="yes">%h</name> 115 + <service> 116 + <type>_smb._tcp</type> 117 + <port>445</port> 118 + </service> 119 + <service> 120 + <type>_device-info._tcp</type> 121 + <port>0</port> 122 + <txt-record>model=TimeCapsule8,119</txt-record> 123 + </service> 124 + <service> 125 + <type>_adisk._tcp</type> 126 + <port>9</port> 127 + <txt-record>dk0=adVN=${tm.shareName},adVF=0x82</txt-record> 128 + <txt-record>sys=waMa=0,adVF=0x100</txt-record> 129 + </service> 130 + </service-group> 131 + ''; 132 + }; 133 + 134 + # ── Firewall ───────────────────────────────────────────────────────────────── 135 + # Samba needs TCP 445 (SMB) + 139 (NetBIOS session) and UDP 137-138 (NetBIOS). 136 + # WS-Discovery uses UDP 3702. 137 + networking.firewall = { 138 + allowedTCPPorts = [ 445 139 ]; 139 + allowedUDPPorts = [ 137 138 3702 ]; 140 + }; 141 + 142 + # ── Storage directory ───────────────────────────────────────────────────────── 143 + systemd.tmpfiles.rules = [ 144 + "d ${tm.path} 0750 root sambashare -" 145 + ]; 146 + }
+2
profiles/server-base.nix
··· 2 2 { 3 3 imports = [ 4 4 ../modules/server/packages.nix 5 + ../modules/server/storage.nix 5 6 ../modules/server/services.nix 6 7 ../modules/server/maintenance.nix 7 8 ../modules/server/hardware-health.nix 8 9 ../modules/server/disable-noise.nix 10 + ../modules/server/timemachine.nix 9 11 ]; 10 12 }
-2
settings/config/cloudflare.nix
··· 2 2 # Cloudflare Tunnel configuration. 3 3 # Single tunnel for all services (PDS, Matrix, etc.) 4 4 5 - enable = true; 6 - 7 5 # Tunnel UUID from `cloudflared tunnel create server` 8 6 # Replace this after running that command. 9 7 tunnelId = "63ec1b18-1358-4ee2-9093-713b4e7d9325";
+47 -39
settings/config/darwin.nix
··· 85 85 ]; 86 86 87 87 # GUI applications via Homebrew Cask 88 + # Note: apps available in nixpkgs are installed via darwin.packages below. 88 89 casks = [ 89 90 # Communication 90 - "discord" 91 - "element" # Matrix client 92 - "signal" 93 - "whatsapp" 94 - 91 + "element" # build fails in nixpkgs on darwin (requires Xcode 26 in Nix sandbox) 92 + 95 93 # Productivity 96 - "obsidian" 97 - "visual-studio-code" 98 - "github" # GitHub Desktop 94 + "github" # GitHub Desktop (not in nixpkgs) 99 95 "claude" 100 - 96 + 101 97 # Browsers 102 - "firefox" 103 - 98 + "firefox" # Not available in nixpkgs-darwin 99 + 104 100 # Media & Entertainment 105 - "obs" # OBS Studio 106 - "spotify" 101 + "obs" # OBS Studio (keep in Homebrew — complex macOS plugin deps) 107 102 "handbrake" 108 - 103 + 109 104 # Gaming 110 - "prismlauncher" # Minecraft launcher 111 105 "steam" 112 - "steam-link" 113 106 "epic-games" 114 - "utm" # Virtual machines 115 - 107 + "prismlauncher" # wayland dep build failure in nixpkgs on darwin (issue #455247) 108 + "utm" 109 + 116 110 # Utilities 117 - "onedrive" 118 111 "cloudflare-warp" 119 - "tailscale" # VPN for inter-host communication 120 - "the-unarchiver" 121 - "transmission" # BitTorrent client 122 - "filezilla" # FTP client 123 - "onyx" # System maintenance 124 - "mos" # Mouse/trackpad customization 125 - "parsec" # Remote desktop 126 - 112 + "tailscale-app" # Renamed from tailscale 113 + # filezilla — not available on macOS in Homebrew or nixpkgs; use Cyberduck or ForkLift instead 114 + "parsec" # Remote desktop (Linux-only in nixpkgs) 115 + "onyx" 116 + "mos" # Mouse/trackpad customization (macOS-specific) 117 + 127 118 # Office & Documents 128 119 "microsoft-excel" 129 120 "microsoft-powerpoint" 130 121 "microsoft-teams" 131 122 "microsoft-word" 132 123 "libreoffice" 133 - 124 + 134 125 # Hardware 135 126 "logitune" # Logitech webcam 136 127 "logitech-options" # Logitech devices 137 - 128 + 138 129 # Other 139 130 "netnewswire" # RSS reader 131 + "altserver" # AltStore sideloading server 140 132 ]; 141 133 142 134 # Mac App Store apps (by ID) 143 135 masApps = { 144 - "Amphetamine" = 937984704; 145 - "EA app" = 1246969117; 146 - "Mini Motorways" = 1453901000; 136 + "Amphetamine" = 937984704; 137 + # Mini Motorways — Apple Arcade, not available via MAS ID 138 + "OneDrive" = 823766827; # moved from casks 147 139 "OP Auto Clicker" = 6754914118; 148 - "Roblox" = 1319456934; 149 - "TestFlight" = 899247664; 150 - "Zone Bar" = 6755328989; 140 + "Steam Link" = 1246969117; # was incorrectly labelled "EA app" 141 + "TestFlight" = 899247664; 142 + "The Unarchiver" = 425424353; # moved from casks 143 + "WhatsApp" = 310633997; # moved from casks 144 + "Zone Bar" = 6755328989; 151 145 }; 152 146 }; 153 147 ··· 157 151 # GNU replacements for the BSD tools macOS ships by default. 158 152 packages = [ 159 153 # GNU replacements for BSD tools macOS ships 160 - "coreutils" # GNU ls/cp/mv/etc (macOS has BSD variants) 161 - "parallel" # GNU parallel 162 - "stow" # GNU stow (symlink farm manager) 163 - "netcat" # GNU netcat (macOS has BSD nc) 154 + "coreutils" # GNU ls/cp/mv/etc (macOS has BSD variants) 155 + "parallel" # GNU parallel 156 + "stow" # GNU stow (symlink farm manager) 157 + "netcat" # GNU netcat (macOS has BSD nc) 164 158 165 159 # Dev libraries needed on PATH for building on macOS 166 160 # (on NixOS these are pulled in automatically as build deps) ··· 170 164 "pcre" 171 165 "pcre2" 172 166 "libffi" 167 + 168 + # ── GUI apps (migrated from Homebrew Cask) ──────────────────────────────── 169 + # These are available in nixpkgs and managed declaratively. 170 + # mac-app-util (already in the flake) ensures they appear in Spotlight/Launchpad. 171 + "discord" # Communication 172 + "signal-desktop-bin" # Signal — officially the darwin path per nixpkgs 25.11 release notes 173 + # element-desktop — build requires Xcode 26 unavailable in Nix sandbox on darwin 174 + "obsidian" # Note-taking 175 + "vscode" # Editor (note: vscode-fhs fails on darwin, plain vscode is fine) 176 + "spotify" # Music 177 + "transmission_4" # BitTorrent client 178 + # filezilla — Linux-only in nixpkgs, installed via Homebrew cask instead 179 + # parsec-bin — Linux-only in nixpkgs, installed via Homebrew cask instead 180 + # prismlauncher — wayland dep build failure on darwin (nixpkgs issue #455247) 173 181 ]; 174 182 175 183 }
+28 -20
settings/config/default.nix
··· 1 + let 2 + # Import server config once so we can read the service toggles below. 3 + serverCfg = import ./server.nix; 4 + svcToggles = serverCfg.services; 5 + in 1 6 { 2 7 # ============================================================================ 3 8 # CENTRAL CONFIGURATION - SINGLE SOURCE OF TRUTH ··· 17 22 # - audio.nix : Audio backend configuration 18 23 # - gaming.nix : Gaming-related settings 19 24 # - server.nix : Server-specific configuration 25 + # ↳ services { } — master on/off switches for all services 20 26 # - darwin.nix : macOS-specific settings 21 27 # - secrets.nix : Secrets management configuration 22 28 # - development.nix : Development tools and languages 23 29 # - maintenance.nix : Backup and auto-update settings 24 - # - paths.nix : Important path locations 25 30 # - pds.nix : Bluesky Personal Data Server settings 26 31 # - matrix.nix : Matrix Synapse homeserver settings 27 32 # - forgejo.nix : Forgejo git forge settings 28 33 # - cloudflare.nix : Cloudflare Tunnel configuration 29 - 30 - user = import ./user.nix; 31 - system = import ./system.nix; 32 - nix = import ./nix.nix; 33 - packages = import ./packages.nix; 34 - git = import ./git.nix; 35 - shell = import ./shell.nix; 36 - desktop = import ./desktop.nix; 37 - ssh = import ./ssh.nix; 38 - audio = import ./audio.nix; 39 - gaming = import ./gaming.nix; 40 - server = import ./server.nix; 41 - darwin = import ./darwin.nix; 42 - secrets = import ./secrets.nix; 34 + 35 + user = import ./user.nix; 36 + system = import ./system.nix; 37 + nix = import ./nix.nix; 38 + packages = import ./packages.nix; 39 + git = import ./git.nix; 40 + shell = import ./shell.nix; 41 + desktop = import ./desktop.nix; 42 + ssh = import ./ssh.nix; 43 + audio = import ./audio.nix; 44 + gaming = import ./gaming.nix; 45 + server = serverCfg; 46 + darwin = import ./darwin.nix; 47 + secrets = import ./secrets.nix; 43 48 development = import ./development.nix; 44 49 maintenance = import ./maintenance.nix; 45 - paths = import ./paths.nix; 46 - pds = import ./pds.nix; 47 - matrix = import ./matrix.nix; 48 - forgejo = import ./forgejo.nix; 49 - cloudflare = import ./cloudflare.nix; 50 + 51 + # Service configs — the `enable` flag is driven by server.nix `services.*` 52 + # so there is a single place to turn services on/off. All other settings 53 + # (ports, hostnames, restart policy …) remain in the individual files. 54 + forgejo = (import ./forgejo.nix) // { enable = svcToggles.forgejo; }; 55 + pds = (import ./pds.nix) // { enable = svcToggles.pds; }; 56 + matrix = (import ./matrix.nix) // { enable = svcToggles.matrix; }; 57 + cloudflare = (import ./cloudflare.nix) // { enable = svcToggles.cloudflare; }; 50 58 }
+1 -7
settings/config/forgejo.nix
··· 3 3 # Non-secret settings only. Secrets (secret key, mailer password, etc.) 4 4 # live in secrets/age/forgejo.env.age. 5 5 6 - enable = true; 7 - 8 6 # Public hostname. 9 7 hostname = "git.ewancroft.uk"; 10 8 ··· 19 17 20 18 # Disable public registration — invite-only or admin-created accounts only. 21 19 disableRegistration = true; 22 - 23 - # systemd restart policy 24 - restartSec = 5; 25 - startLimitIntervalSec = 300; 26 - startLimitBurst = 5; 20 + # Restart policy is shared: see settings/config/server.nix → servicePolicy. 27 21 }
+1 -7
settings/config/matrix.nix
··· 3 3 # Non-secret settings only. Secrets (registration_shared_secret, macaroon_secret_key) 4 4 # should be stored in secrets/age/matrix.env.age. 5 5 6 - enable = true; 7 - 8 6 # Public hostname — also used as the Caddy virtual host and the Cloudflare 9 7 # tunnel public hostname. 10 8 hostname = "matrix.ewancroft.uk"; ··· 18 16 19 17 # Caddy internal listen port — Cloudflare tunnel routes here. 20 18 caddyPort = 8448; 21 - 22 - # systemd restart policy 23 - restartSec = 5; 24 - startLimitIntervalSec = 300; 25 - startLimitBurst = 5; 19 + # Restart policy is shared: see settings/config/server.nix → servicePolicy. 26 20 }
-4
settings/config/paths.nix
··· 1 - { 2 - # Paths & locations 3 - configRepo = "~/.config/nix-config"; 4 - }
+1 -7
settings/config/pds.nix
··· 4 4 # PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX, PDS_EMAIL_SMTP_URL, 5 5 # PDS_EMAIL_FROM_ADDRESS) live in secrets/age/pds.env.age. 6 6 7 - enable = true; 8 - 9 7 # Public hostname — also used as the Caddy virtual host and the Cloudflare 10 8 # tunnel public hostname. Subdomains are used for account handles. 11 9 hostname = "pds.ewancroft.uk"; ··· 35 33 36 34 # Caddy internal listen port — Cloudflare tunnel routes here. 37 35 caddyPort = 2020; 38 - 39 - # systemd restart policy 40 - restartSec = 5; 41 - startLimitIntervalSec = 300; 42 - startLimitBurst = 5; 36 + # Restart policy is shared: see settings/config/server.nix → servicePolicy. 43 37 }
+66
settings/config/server.nix
··· 1 1 { 2 2 # Server configuration 3 3 4 + # ─── Service toggles ───────────────────────────────────────────────────────── 5 + # Master on/off switches for every server service. 6 + # The detailed settings for each service live in their own files (e.g. 7 + # settings/config/forgejo.nix) — only the enable flag lives here so you 8 + # have one place to see what's running. 9 + services = { 10 + forgejo = true; # Forgejo git forge (git.ewancroft.uk) 11 + pds = true; # Bluesky ATProto PDS (pds.ewancroft.uk) 12 + matrix = true; # Matrix Synapse homeserver (matrix.ewancroft.uk) 13 + cloudflare = true; # Cloudflare tunnel (outbound, all services) 14 + }; 15 + 16 + # ─── Time Machine ───────────────────────────────────────────────────────────── 17 + # Exposes an SMB share (with the fruit VFS module) that macOS backs up to. 18 + # Requires AFP/Samba — AFP was dropped in Ventura so SMB + fruit is used. 19 + # 20 + # After deploying, create a Samba user for each Mac: 21 + # sudo smbpasswd -a <username> 22 + # Then add the backup disk in macOS System Settings -> General -> Time Machine. 23 + timemachine = { 24 + enable = false; # set to true to activate 25 + shareName = "TimeMachine"; # name shown to macOS 26 + path = "/srv/timemachine"; # where backups are stored 27 + maxSizeGB = 0; # 0 = unlimited; set e.g. 500 to cap at 500 GB 28 + validUsers = [ ]; # e.g. [ "ewan" ] — must have a Samba password set 29 + }; 30 + 31 + # ─── Storage ────────────────────────────────────────────────────────────────── 32 + # /srv is mounted as a separate partition to isolate all service data 33 + # (forgejo, matrix-synapse, postgresql, bluesky-pds, www) from the root volume. 34 + # 35 + # Point `device` at the raw block device you want to use. 36 + # Run `lsblk` on the server to find the right path, e.g. /dev/sdb or /dev/sdb1. 37 + # 38 + # The system will automatically: 39 + # 1. Format the partition with ext4 (only if it has no filesystem yet) 40 + # 2. Mount it at /srv 41 + # 3. Create all required subdirectories with correct ownership 42 + # No manual setup is needed — just set the device and deploy. 43 + storage = { 44 + srv = { 45 + device = "/dev/sdb"; # ← set to your partition (use `lsblk` to find it) 46 + fsType = "ext4"; 47 + options = [ "defaults" "noatime" ]; 48 + # Subdirectories created automatically under /srv 49 + # Each service uses its own subdirectory 50 + }; 51 + }; 52 + 53 + # ─── Cockpit dashboard ────────────────────────────────────────────────────────── 54 + # Web-based server status dashboard (services, journals, metrics, terminal). 55 + # Accessible only over Tailscale — not exposed publicly. 56 + cockpit = { 57 + enable = true; 58 + port = 9090; # Cockpit default 59 + }; 60 + 61 + # ─── Shared systemd service restart policy ─────────────────────────────────── 62 + # Applied by default to forgejo, matrix-synapse, and bluesky-pds. 63 + # Override per-service by reading this value in the module and using lib.mkForce. 64 + servicePolicy = { 65 + restartSec = 5; # seconds before restarting after a crash 66 + startLimitIntervalSec = 300; # window for startLimitBurst 67 + startLimitBurst = 5; # max restarts within the window before giving up 68 + }; 69 + 4 70 # SSH daemon 5 71 sshd = { 6 72 enable = true;
+9
tools/Cargo.toml
··· 3 3 version = "0.1.0" 4 4 edition = "2021" 5 5 6 + [dependencies] 7 + dialoguer = { version = "0.11", features = ["completion"] } 8 + console = "0.15" 9 + regex = "1" 10 + 6 11 [lib] 7 12 name = "tools_common" 8 13 path = "src/lib.rs" ··· 20 25 [[bin]] 21 26 name = "health-check" 22 27 path = "src/bin/health-check.rs" 28 + 29 + [[bin]] 30 + name = "server-config" 31 + path = "src/bin/server-config.rs" 23 32 24 33 # ── Removed tools (source kept for reference, not compiled) ────────────────── 25 34 # darwin-export — unnecessary: macOS settings are now fully declarative
+129
tools/check-darwin.sh
··· 1 + #!/usr/bin/env bash 2 + # check-darwin.sh — validate all darwin.nix entries before running nrs 3 + # Usage: ./tools/check-darwin.sh 4 + # Exits 0 if everything looks good, 1 if any issues found. 5 + 6 + set -euo pipefail 7 + 8 + PASS="✅" 9 + FAIL="❌" 10 + WARN="⚠️ " 11 + issues=0 12 + 13 + # ── helpers ─────────────────────────────────────────────────────────────────── 14 + 15 + check_brew() { 16 + local name=$1 17 + if brew info "$name" &>/dev/null; then 18 + echo " $PASS brew: $name" 19 + else 20 + echo " $FAIL brew: $name ← not found in Homebrew" 21 + (( issues++ )) || true 22 + fi 23 + } 24 + 25 + check_cask() { 26 + local name=$1 27 + if brew info --cask "$name" &>/dev/null; then 28 + echo " $PASS cask: $name" 29 + else 30 + echo " $FAIL cask: $name ← not found in Homebrew casks" 31 + (( issues++ )) || true 32 + fi 33 + } 34 + 35 + check_mas() { 36 + local name=$1 id=$2 37 + if mas info "$id" &>/dev/null; then 38 + echo " $PASS mas: $name ($id)" 39 + else 40 + echo " $FAIL mas: $name ($id) ← ID not found in App Store" 41 + (( issues++ )) || true 42 + fi 43 + } 44 + 45 + check_nixpkg() { 46 + local attr=$1 47 + # Evaluate against nixpkgs-darwin for aarch64 (matches macmini flake input) 48 + if nix eval --impure \ 49 + --expr "let p = import <nixpkgs> { system = \"aarch64-darwin\"; config.allowUnfree = true; }; in p.${attr}.name" \ 50 + &>/dev/null; then 51 + echo " $PASS nixpkg: $attr" 52 + else 53 + echo " $FAIL nixpkg: $attr ← not available / wrong attr / unsupported on aarch64-darwin" 54 + (( issues++ )) || true 55 + fi 56 + } 57 + 58 + # ── brews ───────────────────────────────────────────────────────────────────── 59 + 60 + echo "" 61 + echo "── Homebrew formulas ────────────────────────────────────────────────────" 62 + for name in \ 63 + libmediainfo media-info libzen \ 64 + aribb24 dav1d rav1e svt-av1 x264 x265 xvid webp aom jpeg-xl highway \ 65 + flac lame opus vorbis-tools libsndfile libsamplerate rubberband speex theora mpg123 \ 66 + little-cms2 leptonica \ 67 + rtmpdump srt librist libmms \ 68 + lzo snappy xxhash yyjson \ 69 + freetds unixodbc \ 70 + summarize goat mas 71 + do 72 + check_brew "$name" 73 + done 74 + 75 + # ── casks ───────────────────────────────────────────────────────────────────── 76 + 77 + echo "" 78 + echo "── Homebrew casks ───────────────────────────────────────────────────────" 79 + for name in \ 80 + element \ 81 + github claude \ 82 + firefox \ 83 + obs handbrake \ 84 + steam epic-games prismlauncher utm \ 85 + cloudflare-warp tailscale-app parsec onyx mos \ 86 + microsoft-excel microsoft-powerpoint microsoft-teams microsoft-word libreoffice \ 87 + logitune logitech-options \ 88 + netnewswire altserver 89 + do 90 + check_cask "$name" 91 + done 92 + 93 + # ── MAS apps ────────────────────────────────────────────────────────────────── 94 + 95 + echo "" 96 + echo "── Mac App Store apps ───────────────────────────────────────────────────" 97 + check_mas "Amphetamine" 937984704 98 + # Mini Motorways is Apple Arcade — no MAS ID, managed by Arcade subscription 99 + check_mas "OneDrive" 823766827 100 + check_mas "OP Auto Clicker" 6754914118 101 + check_mas "Steam Link" 1246969117 102 + check_mas "TestFlight" 899247664 103 + check_mas "The Unarchiver" 425424353 104 + check_mas "WhatsApp" 310633997 105 + check_mas "Zone Bar" 6755328989 106 + 107 + # ── nixpkgs packages (aarch64-darwin) ───────────────────────────────────────── 108 + 109 + echo "" 110 + echo "── nixpkgs packages (aarch64-darwin) ────────────────────────────────────" 111 + echo " (uses <nixpkgs> from your NIX_PATH — run 'nix flake update' first if stale)" 112 + for attr in \ 113 + coreutils parallel stow netcat \ 114 + openssl readline ncurses pcre pcre2 libffi \ 115 + discord signal-desktop-bin obsidian vscode spotify transmission_4 116 + do 117 + check_nixpkg "$attr" 118 + done 119 + 120 + # ── summary ─────────────────────────────────────────────────────────────────── 121 + 122 + echo "" 123 + echo "─────────────────────────────────────────────────────────────────────────" 124 + if [[ $issues -eq 0 ]]; then 125 + echo "$PASS All checks passed — safe to run nrs" 126 + else 127 + echo "$FAIL $issues issue(s) found — fix darwin.nix before running nrs" 128 + exit 1 129 + fi
+6
tools/flake.nix
··· 32 32 # Pre-rebuild preflight: daemon, lock, eval, git, age key, disk space. 33 33 # Usage: nix run .#health-check 34 34 health-check = { type = "app"; program = "${pkg}/bin/health-check"; }; 35 + 36 + # Interactive server configurator: service toggles, storage device, 37 + # Cockpit, Forgejo, Matrix, PDS, Cloudflare settings. 38 + # Usage: nix run .#server-config 39 + # nix run .#server-config -- --show (read-only summary) 40 + server-config = { type = "app"; program = "${pkg}/bin/server-config"; }; 35 41 } 36 42 ); 37 43 };
+542
tools/src/bin/server-config.rs
··· 1 + /// server-config — interactive configurator for the NixOS server settings. 2 + /// 3 + /// Reads the current values from settings/config/{server,forgejo,matrix,pds,cloudflare}.nix, 4 + /// presents an interactive menu to change them, then writes the modified files back in-place. 5 + /// 6 + /// Usage: 7 + /// nix run .#server-config # interactive (full menu) 8 + /// nix run .#server-config -- --show # print current config and exit 9 + use console::Style; 10 + use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select}; 11 + use regex::Regex; 12 + use std::fmt::Write as _; 13 + use tools_common::*; 14 + 15 + // ── helpers ────────────────────────────────────────────────────────────────── 16 + 17 + fn theme() -> ColorfulTheme { ColorfulTheme::default() } 18 + 19 + /// Replace the value of a Nix attribute in-place. 20 + /// key = the bare attribute name (e.g. `"device"`) 21 + /// value = the new Nix literal to write (e.g. `"\"/dev/sdb1\""`) 22 + /// 23 + /// Handles: 24 + /// key = "string"; 25 + /// key = true; / key = false; 26 + /// key = 1234; 27 + fn nix_set_scalar(src: &str, key: &str, value: &str) -> String { 28 + // Match `key = <anything up to semicolon>;` with optional whitespace 29 + let pattern = format!(r"(?m)(\b{key}\s*=\s*)([^;]+)(;)"); 30 + let re = Regex::new(&pattern).unwrap(); 31 + if re.is_match(src) { 32 + re.replace(src, format!("${{1}}{value}${{3}}")).into_owned() 33 + } else { 34 + eprintln!("⚠️ key '{key}' not found — skipping"); 35 + src.to_string() 36 + } 37 + } 38 + 39 + /// Read a scalar value from a Nix file. 40 + fn nix_get_scalar<'a>(src: &'a str, key: &str) -> Option<&'a str> { 41 + let pattern = format!(r"(?m)\b{key}\s*=\s*([^;]+);"); 42 + let re = Regex::new(&pattern).unwrap(); 43 + re.captures(src).map(|c| { 44 + // We can't return a lifetime-bound reference from a local Regex, 45 + // so we find the match start manually. 46 + let m = re.find(src).unwrap(); 47 + let cap_start = m.start() + c.get(0).unwrap().as_str().find(c.get(1).unwrap().as_str()).unwrap(); 48 + &src[cap_start..cap_start + c.get(1).unwrap().as_str().len()] 49 + }) 50 + } 51 + 52 + fn strip_nix_string(s: &str) -> String { 53 + s.trim().trim_matches('"').to_string() 54 + } 55 + 56 + fn read_file(path: &Path) -> String { 57 + fs::read_to_string(path).unwrap_or_else(|e| { 58 + eprintln!("❌ Cannot read {}: {}", path.display(), e); 59 + std::process::exit(1); 60 + }) 61 + } 62 + 63 + fn write_file(path: &Path, content: &str) { 64 + fs::write(path, content).unwrap_or_else(|e| { 65 + eprintln!("❌ Cannot write {}: {}", path.display(), e); 66 + std::process::exit(1); 67 + }); 68 + } 69 + 70 + // ── config representation ──────────────────────────────────────────────────── 71 + 72 + #[derive(Debug, Clone)] 73 + struct ServiceToggles { 74 + forgejo: bool, 75 + pds: bool, 76 + matrix: bool, 77 + cloudflare: bool, 78 + } 79 + 80 + #[derive(Debug, Clone)] 81 + struct StorageConfig { 82 + device: String, 83 + fs_type: String, 84 + } 85 + 86 + #[derive(Debug, Clone)] 87 + struct CockpitConfig { 88 + enable: bool, 89 + port: u16, 90 + } 91 + 92 + #[derive(Debug, Clone)] 93 + struct ForgejoConfig { 94 + hostname: String, 95 + port: u16, 96 + caddy_port: u16, 97 + app_name: String, 98 + disable_registration: bool, 99 + } 100 + 101 + #[derive(Debug, Clone)] 102 + struct MatrixConfig { 103 + hostname: String, 104 + server_name: String, 105 + port: u16, 106 + caddy_port: u16, 107 + } 108 + 109 + #[derive(Debug, Clone)] 110 + struct PdsConfig { 111 + hostname: String, 112 + port: u16, 113 + caddy_port: u16, 114 + admin_email: String, 115 + } 116 + 117 + #[derive(Debug, Clone)] 118 + struct CloudflareConfig { 119 + tunnel_id: String, 120 + } 121 + 122 + // ── readers ────────────────────────────────────────────────────────────────── 123 + 124 + fn parse_bool(s: &str) -> bool { s.trim() == "true" } 125 + fn parse_u16(s: &str) -> u16 { s.trim().parse().unwrap_or(0) } 126 + 127 + fn read_services(src: &str) -> ServiceToggles { 128 + ServiceToggles { 129 + forgejo: parse_bool(nix_get_scalar(src, "forgejo").unwrap_or("true")), 130 + pds: parse_bool(nix_get_scalar(src, "pds").unwrap_or("true")), 131 + matrix: parse_bool(nix_get_scalar(src, "matrix").unwrap_or("true")), 132 + cloudflare: parse_bool(nix_get_scalar(src, "cloudflare").unwrap_or("true")), 133 + } 134 + } 135 + 136 + fn read_storage(src: &str) -> StorageConfig { 137 + StorageConfig { 138 + device: strip_nix_string(nix_get_scalar(src, "device").unwrap_or("\"/dev/sdb\"")), 139 + fs_type: strip_nix_string(nix_get_scalar(src, "fsType").unwrap_or("\"ext4\"")), 140 + } 141 + } 142 + 143 + fn read_cockpit(src: &str) -> CockpitConfig { 144 + // cockpit.enable lives alongside other booleans so we need to find it 145 + // after the "cockpit" heading comment 146 + let enable = if let Some(pos) = src.find("cockpit = {") { 147 + parse_bool(nix_get_scalar(&src[pos..], "enable").unwrap_or("true")) 148 + } else { true }; 149 + let port = if let Some(pos) = src.find("cockpit = {") { 150 + parse_u16(nix_get_scalar(&src[pos..], "port").unwrap_or("9090")) 151 + } else { 9090 }; 152 + CockpitConfig { enable, port } 153 + } 154 + 155 + fn read_forgejo(src: &str) -> ForgejoConfig { 156 + ForgejoConfig { 157 + hostname: strip_nix_string(nix_get_scalar(src, "hostname").unwrap_or("\"git.ewancroft.uk\"")), 158 + port: parse_u16(nix_get_scalar(src, "port").unwrap_or("3001")), 159 + caddy_port: parse_u16(nix_get_scalar(src, "caddyPort").unwrap_or("3002")), 160 + app_name: strip_nix_string(nix_get_scalar(src, "appName").unwrap_or("\"Forgejo\"")), 161 + disable_registration: parse_bool(nix_get_scalar(src, "disableRegistration").unwrap_or("true")), 162 + } 163 + } 164 + 165 + fn read_matrix(src: &str) -> MatrixConfig { 166 + MatrixConfig { 167 + hostname: strip_nix_string(nix_get_scalar(src, "hostname").unwrap_or("\"matrix.ewancroft.uk\"")), 168 + server_name: strip_nix_string(nix_get_scalar(src, "serverName").unwrap_or("\"ewancroft.uk\"")), 169 + port: parse_u16(nix_get_scalar(src, "port").unwrap_or("8008")), 170 + caddy_port: parse_u16(nix_get_scalar(src, "caddyPort").unwrap_or("8448")), 171 + } 172 + } 173 + 174 + fn read_pds(src: &str) -> PdsConfig { 175 + PdsConfig { 176 + hostname: strip_nix_string(nix_get_scalar(src, "hostname").unwrap_or("\"pds.ewancroft.uk\"")), 177 + port: parse_u16(nix_get_scalar(src, "port").unwrap_or("3000")), 178 + caddy_port: parse_u16(nix_get_scalar(src, "caddyPort").unwrap_or("2020")), 179 + admin_email: strip_nix_string(nix_get_scalar(src, "adminEmail").unwrap_or("\"admin@example.com\"")), 180 + } 181 + } 182 + 183 + fn read_cloudflare(src: &str) -> CloudflareConfig { 184 + CloudflareConfig { 185 + tunnel_id: strip_nix_string(nix_get_scalar(src, "tunnelId").unwrap_or("\"<unset>\"")), 186 + } 187 + } 188 + 189 + // ── writers ────────────────────────────────────────────────────────────────── 190 + 191 + fn write_services(src: &str, s: &ServiceToggles) -> String { 192 + // The service toggles sit inside a `services = { … }` block. Because all 193 + // four keys are bare booleans we can replace them by key name directly. 194 + // We scope each replacement to avoid touching unrelated `enable` fields. 195 + let src = nix_set_scalar(src, "forgejo", &s.forgejo.to_string()); 196 + let src = nix_set_scalar(&src, "pds", &s.pds.to_string()); 197 + let src = nix_set_scalar(&src, "matrix", &s.matrix.to_string()); 198 + nix_set_scalar(&src, "cloudflare", &s.cloudflare.to_string()) 199 + } 200 + 201 + fn write_storage(src: &str, st: &StorageConfig) -> String { 202 + let src = nix_set_scalar(src, "device", &format!("\"{}\"", st.device)); 203 + nix_set_scalar(&src, "fsType", &format!("\"{}\"", st.fs_type)) 204 + } 205 + 206 + fn write_cockpit(src: &str, c: &CockpitConfig) -> String { 207 + // Cockpit block comes AFTER the storage block in server.nix so we 208 + // replace only within the cockpit = { … } section. 209 + let block_start = src.find("cockpit = {").unwrap_or(0); 210 + let (before, after) = src.split_at(block_start); 211 + let after = nix_set_scalar(after, "enable", &c.enable.to_string()); 212 + let after = nix_set_scalar(&after, "port", &c.port.to_string()); 213 + format!("{before}{after}") 214 + } 215 + 216 + fn write_forgejo(src: &str, f: &ForgejoConfig) -> String { 217 + let src = nix_set_scalar(src, "hostname", &format!("\"{}\"", f.hostname)); 218 + let src = nix_set_scalar(&src, "port", &f.port.to_string()); 219 + let src = nix_set_scalar(&src, "caddyPort", &f.caddy_port.to_string()); 220 + let src = nix_set_scalar(&src, "appName", &format!("\"{}\"", f.app_name)); 221 + nix_set_scalar(&src, "disableRegistration", &f.disable_registration.to_string()) 222 + } 223 + 224 + fn write_matrix(src: &str, m: &MatrixConfig) -> String { 225 + let src = nix_set_scalar(src, "hostname", &format!("\"{}\"", m.hostname)); 226 + let src = nix_set_scalar(&src, "serverName",&format!("\"{}\"", m.server_name)); 227 + let src = nix_set_scalar(&src, "port", &m.port.to_string()); 228 + nix_set_scalar(&src, "caddyPort", &m.caddy_port.to_string()) 229 + } 230 + 231 + fn write_pds(src: &str, p: &PdsConfig) -> String { 232 + let src = nix_set_scalar(src, "hostname", &format!("\"{}\"", p.hostname)); 233 + let src = nix_set_scalar(&src, "port", &p.port.to_string()); 234 + let src = nix_set_scalar(&src, "caddyPort", &p.caddy_port.to_string()); 235 + nix_set_scalar(&src, "adminEmail", &format!("\"{}\"", p.admin_email)) 236 + } 237 + 238 + fn write_cloudflare(src: &str, c: &CloudflareConfig) -> String { 239 + nix_set_scalar(src, "tunnelId", &format!("\"{}\"", c.tunnel_id)) 240 + } 241 + 242 + // ── display ─────────────────────────────────────────────────────────────────── 243 + 244 + fn bool_str(b: bool) -> &'static str { if b { "enabled" } else { "disabled" } } 245 + 246 + fn print_summary( 247 + svc: &ServiceToggles, st: &StorageConfig, ck: &CockpitConfig, 248 + fg: &ForgejoConfig, mx: &MatrixConfig, pd: &PdsConfig, cf: &CloudflareConfig, 249 + ) { 250 + let h1 = Style::new().bold().cyan(); 251 + let kv = |k: &str, v: &str| println!(" {:<26} {}", format!("{k}:"), v); 252 + 253 + println!("\n{}", h1.apply_to(" ── Service toggles ──────────────────────")); 254 + kv("forgejo", bool_str(svc.forgejo)); 255 + kv("pds", bool_str(svc.pds)); 256 + kv("matrix", bool_str(svc.matrix)); 257 + kv("cloudflare", bool_str(svc.cloudflare)); 258 + 259 + println!("\n{}", h1.apply_to(" ── /srv storage ──────────────────────────")); 260 + kv("device", &st.device); 261 + kv("fsType", &st.fs_type); 262 + 263 + println!("\n{}", h1.apply_to(" ── Cockpit dashboard ─────────────────────")); 264 + kv("enable", bool_str(ck.enable)); 265 + kv("port", &ck.port.to_string()); 266 + 267 + println!("\n{}", h1.apply_to(" ── Forgejo ───────────────────────────────")); 268 + kv("hostname", &fg.hostname); 269 + kv("port", &fg.port.to_string()); 270 + kv("caddyPort", &fg.caddy_port.to_string()); 271 + kv("appName", &fg.app_name); 272 + kv("disableRegistration", bool_str(fg.disable_registration)); 273 + 274 + println!("\n{}", h1.apply_to(" ── Matrix Synapse ────────────────────────")); 275 + kv("hostname", &mx.hostname); 276 + kv("serverName", &mx.server_name); 277 + kv("port", &mx.port.to_string()); 278 + kv("caddyPort", &mx.caddy_port.to_string()); 279 + 280 + println!("\n{}", h1.apply_to(" ── Bluesky PDS ───────────────────────────")); 281 + kv("hostname", &pd.hostname); 282 + kv("port", &pd.port.to_string()); 283 + kv("caddyPort", &pd.caddy_port.to_string()); 284 + kv("adminEmail", &pd.admin_email); 285 + 286 + println!("\n{}", h1.apply_to(" ── Cloudflare Tunnel ─────────────────────")); 287 + kv("tunnelId", &cf.tunnel_id); 288 + 289 + println!(); 290 + } 291 + 292 + // ── interactive sections ────────────────────────────────────────────────────── 293 + 294 + fn edit_services(svc: &mut ServiceToggles) { 295 + let names = ["forgejo", "pds (Bluesky ATProto)", "matrix", "cloudflare tunnel"]; 296 + let current = [svc.forgejo, svc.pds, svc.matrix, svc.cloudflare]; 297 + let defaults: Vec<bool> = current.to_vec(); 298 + 299 + let selected = MultiSelect::with_theme(&theme()) 300 + .with_prompt("Select services to ENABLE (space = toggle, enter = confirm)") 301 + .items(&names) 302 + .defaults(&defaults) 303 + .interact() 304 + .unwrap(); 305 + 306 + svc.forgejo = selected.contains(&0); 307 + svc.pds = selected.contains(&1); 308 + svc.matrix = selected.contains(&2); 309 + svc.cloudflare = selected.contains(&3); 310 + } 311 + 312 + fn edit_storage(st: &mut StorageConfig) { 313 + st.device = Input::with_theme(&theme()) 314 + .with_prompt("/srv block device (e.g. /dev/sdb, /dev/sdb1)") 315 + .with_initial_text(&st.device) 316 + .interact_text().unwrap(); 317 + 318 + let fs_opts = ["ext4", "xfs", "btrfs"]; 319 + let current_idx = fs_opts.iter().position(|&f| f == st.fs_type).unwrap_or(0); 320 + let sel = Select::with_theme(&theme()) 321 + .with_prompt("Filesystem type") 322 + .items(&fs_opts) 323 + .default(current_idx) 324 + .interact().unwrap(); 325 + st.fs_type = fs_opts[sel].to_string(); 326 + } 327 + 328 + fn edit_cockpit(ck: &mut CockpitConfig) { 329 + ck.enable = Confirm::with_theme(&theme()) 330 + .with_prompt("Enable Cockpit dashboard?") 331 + .default(ck.enable) 332 + .interact().unwrap(); 333 + 334 + if ck.enable { 335 + let new_port: String = Input::with_theme(&theme()) 336 + .with_prompt("Cockpit port (accessible over Tailscale only)") 337 + .with_initial_text(&ck.port.to_string()) 338 + .interact_text().unwrap(); 339 + ck.port = new_port.trim().parse().unwrap_or(ck.port); 340 + } 341 + } 342 + 343 + fn edit_forgejo(fg: &mut ForgejoConfig) { 344 + fg.hostname = Input::with_theme(&theme()) 345 + .with_prompt("Forgejo public hostname") 346 + .with_initial_text(&fg.hostname) 347 + .interact_text().unwrap(); 348 + 349 + fg.app_name = Input::with_theme(&theme()) 350 + .with_prompt("Forgejo display name") 351 + .with_initial_text(&fg.app_name) 352 + .interact_text().unwrap(); 353 + 354 + let p: String = Input::with_theme(&theme()) 355 + .with_prompt("Forgejo internal port") 356 + .with_initial_text(&fg.port.to_string()) 357 + .interact_text().unwrap(); 358 + fg.port = p.trim().parse().unwrap_or(fg.port); 359 + 360 + let cp: String = Input::with_theme(&theme()) 361 + .with_prompt("Caddy internal port (tunnel → Caddy → Forgejo)") 362 + .with_initial_text(&fg.caddy_port.to_string()) 363 + .interact_text().unwrap(); 364 + fg.caddy_port = cp.trim().parse().unwrap_or(fg.caddy_port); 365 + 366 + fg.disable_registration = Confirm::with_theme(&theme()) 367 + .with_prompt("Disable public registration?") 368 + .default(fg.disable_registration) 369 + .interact().unwrap(); 370 + } 371 + 372 + fn edit_matrix(mx: &mut MatrixConfig) { 373 + mx.hostname = Input::with_theme(&theme()) 374 + .with_prompt("Matrix public hostname (e.g. matrix.example.com)") 375 + .with_initial_text(&mx.hostname) 376 + .interact_text().unwrap(); 377 + 378 + mx.server_name = Input::with_theme(&theme()) 379 + .with_prompt("Matrix server name (used in @user:domain IDs)") 380 + .with_initial_text(&mx.server_name) 381 + .interact_text().unwrap(); 382 + 383 + let p: String = Input::with_theme(&theme()) 384 + .with_prompt("Synapse internal port") 385 + .with_initial_text(&mx.port.to_string()) 386 + .interact_text().unwrap(); 387 + mx.port = p.trim().parse().unwrap_or(mx.port); 388 + 389 + let cp: String = Input::with_theme(&theme()) 390 + .with_prompt("Caddy internal port") 391 + .with_initial_text(&mx.caddy_port.to_string()) 392 + .interact_text().unwrap(); 393 + mx.caddy_port = cp.trim().parse().unwrap_or(mx.caddy_port); 394 + } 395 + 396 + fn edit_pds(pd: &mut PdsConfig) { 397 + pd.hostname = Input::with_theme(&theme()) 398 + .with_prompt("PDS public hostname (e.g. pds.example.com)") 399 + .with_initial_text(&pd.hostname) 400 + .interact_text().unwrap(); 401 + 402 + pd.admin_email = Input::with_theme(&theme()) 403 + .with_prompt("PDS admin email") 404 + .with_initial_text(&pd.admin_email) 405 + .interact_text().unwrap(); 406 + 407 + let p: String = Input::with_theme(&theme()) 408 + .with_prompt("PDS internal port") 409 + .with_initial_text(&pd.port.to_string()) 410 + .interact_text().unwrap(); 411 + pd.port = p.trim().parse().unwrap_or(pd.port); 412 + 413 + let cp: String = Input::with_theme(&theme()) 414 + .with_prompt("Caddy internal port") 415 + .with_initial_text(&pd.caddy_port.to_string()) 416 + .interact_text().unwrap(); 417 + pd.caddy_port = cp.trim().parse().unwrap_or(pd.caddy_port); 418 + } 419 + 420 + fn edit_cloudflare(cf: &mut CloudflareConfig) { 421 + cf.tunnel_id = Input::with_theme(&theme()) 422 + .with_prompt("Cloudflare tunnel UUID (from: cloudflared tunnel create server)") 423 + .with_initial_text(&cf.tunnel_id) 424 + .interact_text().unwrap(); 425 + } 426 + 427 + // ── main ───────────────────────────────────────────────────────────────────── 428 + 429 + fn main() { 430 + let args: Vec<String> = env::args().collect(); 431 + let show_only = args.iter().any(|a| a == "--show"); 432 + 433 + let root = git_root(); 434 + let cfg = root.join("settings/config"); 435 + 436 + let server_path = cfg.join("server.nix"); 437 + let forgejo_path = cfg.join("forgejo.nix"); 438 + let matrix_path = cfg.join("matrix.nix"); 439 + let pds_path = cfg.join("pds.nix"); 440 + let cloudflare_path = cfg.join("cloudflare.nix"); 441 + 442 + // Read all files 443 + let mut server_src = read_file(&server_path); 444 + let mut forgejo_src = read_file(&forgejo_path); 445 + let mut matrix_src = read_file(&matrix_path); 446 + let mut pds_src = read_file(&pds_path); 447 + let mut cloudflare_src = read_file(&cloudflare_path); 448 + 449 + // Parse current values 450 + let mut svc = read_services(&server_src); 451 + let mut st = read_storage(&server_src); 452 + let mut ck = read_cockpit(&server_src); 453 + let mut fg = read_forgejo(&forgejo_src); 454 + let mut mx = read_matrix(&matrix_src); 455 + let mut pd = read_pds(&pds_src); 456 + let mut cf = read_cloudflare(&cloudflare_src); 457 + 458 + let title = Style::new().bold().green(); 459 + println!("\n{}", title.apply_to(" 🖥️ Server configurator")); 460 + println!(" Repo: {}\n", root.display()); 461 + 462 + if show_only { 463 + print_summary(&svc, &st, &ck, &fg, &mx, &pd, &cf); 464 + return; 465 + } 466 + 467 + // ── interactive menu loop ───────────────────────────────────────────────── 468 + let menu_items = [ 469 + "Service toggles (forgejo / pds / matrix / cloudflare)", 470 + "/srv storage (block device, filesystem)", 471 + "Cockpit dashboard (enable, port)", 472 + "Forgejo (hostname, ports, app name, registration)", 473 + "Matrix Synapse (hostname, server name, ports)", 474 + "Bluesky PDS (hostname, ports, admin email)", 475 + "Cloudflare Tunnel (tunnel UUID)", 476 + "── Show current config", 477 + "── Save and exit", 478 + "── Exit without saving", 479 + ]; 480 + 481 + loop { 482 + let choice = Select::with_theme(&theme()) 483 + .with_prompt("What do you want to configure?") 484 + .items(&menu_items) 485 + .default(0) 486 + .interact() 487 + .unwrap(); 488 + 489 + match choice { 490 + 0 => edit_services(&mut svc), 491 + 1 => edit_storage(&mut st), 492 + 2 => edit_cockpit(&mut ck), 493 + 3 => edit_forgejo(&mut fg), 494 + 4 => edit_matrix(&mut mx), 495 + 5 => edit_pds(&mut pd), 496 + 6 => edit_cloudflare(&mut cf), 497 + 7 => print_summary(&svc, &st, &ck, &fg, &mx, &pd, &cf), 498 + 8 => { 499 + // Apply changes to source strings 500 + server_src = write_services(&server_src, &svc); 501 + server_src = write_storage(&server_src, &st); 502 + server_src = write_cockpit(&server_src, &ck); 503 + forgejo_src = write_forgejo(&forgejo_src, &fg); 504 + matrix_src = write_matrix(&matrix_src, &mx); 505 + pds_src = write_pds(&pds_src, &pd); 506 + cloudflare_src = write_cloudflare(&cloudflare_src, &cf); 507 + 508 + // Write files 509 + write_file(&server_path, &server_src); 510 + write_file(&forgejo_path, &forgejo_src); 511 + write_file(&matrix_path, &matrix_src); 512 + write_file(&pds_path, &pds_src); 513 + write_file(&cloudflare_path, &cloudflare_src); 514 + 515 + println!("\n✅ Saved. Run `nrs` to apply changes."); 516 + 517 + // Optionally rebuild immediately 518 + if Confirm::with_theme(&theme()) 519 + .with_prompt("Run nixos-rebuild switch now?") 520 + .default(false) 521 + .interact() 522 + .unwrap() 523 + { 524 + let status = Command::new("sudo") 525 + .args(["nixos-rebuild", "switch", "--flake", &format!("{}#server", root.display())]) 526 + .status(); 527 + match status { 528 + Ok(s) if s.success() => println!("✅ Rebuild succeeded."), 529 + Ok(s) => eprintln!("❌ Rebuild exited with status {s}"), 530 + Err(e) => eprintln!("❌ Could not run nixos-rebuild: {e}"), 531 + } 532 + } 533 + break; 534 + } 535 + 9 => { 536 + println!("Exiting without saving."); 537 + break; 538 + } 539 + _ => {} 540 + } 541 + } 542 + }