Kieran's opinionated (and probably slightly dumb) nix config

feat: add cedarlogic deploy

dunkirk.sh a2134fda 63493dba

verified
+234
+10
machines/terebithia/default.nix
··· 165 165 owner = "canvas-mcp"; 166 166 mode = "0400"; 167 167 }; 168 + cedarlogic = { 169 + file = ../../secrets/cedarlogic.age; 170 + owner = "cedarlogic"; 171 + }; 168 172 169 173 "restic/env".file = ../../secrets/restic/env.age; 170 174 "restic/repo".file = ../../secrets/restic/repo.age; ··· 551 555 environment = { 552 556 DKIM_PRIVATE_KEY_FILE = "${config.age.secrets.canvas-mcp-dkim.path}"; 553 557 }; 558 + }; 559 + 560 + atelier.services.cedarlogic = { 561 + enable = true; 562 + domain = "cedarlogic.dunkirk.sh"; 563 + secretsFile = config.age.secrets.cedarlogic.path; 554 564 }; 555 565 556 566 services.caddy.virtualHosts."terebithia.dunkirk.sh" = {
+221
modules/nixos/services/cedarlogic.nix
··· 1 + # CedarLogic - Web-based circuit simulator 2 + # 3 + # Custom module (not mkService) because: 4 + # - App lives in web/ subdirectory of the repo 5 + # - Needs a Vite build step before serving 6 + # - Multi-port: API (3000), Hocuspocus WS (3001), Cursor WS (3002) 7 + # - Caddy needs path-based routing to different backends 8 + 9 + { config, lib, pkgs, ... }: 10 + 11 + let 12 + cfg = config.atelier.services.cedarlogic; 13 + appDir = "${cfg.dataDir}/app"; 14 + webDir = "${appDir}/web"; 15 + in 16 + { 17 + options.atelier.services.cedarlogic = { 18 + enable = lib.mkEnableOption "CedarLogic circuit simulator"; 19 + 20 + domain = lib.mkOption { 21 + type = lib.types.str; 22 + description = "Domain to serve CedarLogic on"; 23 + }; 24 + 25 + dataDir = lib.mkOption { 26 + type = lib.types.path; 27 + default = "/var/lib/cedarlogic"; 28 + description = "Directory to store CedarLogic data"; 29 + }; 30 + 31 + port = lib.mkOption { 32 + type = lib.types.port; 33 + default = 3100; 34 + description = "Port for the HTTP API server"; 35 + }; 36 + 37 + wsPort = lib.mkOption { 38 + type = lib.types.port; 39 + default = 3101; 40 + description = "Port for the Hocuspocus WebSocket server"; 41 + }; 42 + 43 + cursorPort = lib.mkOption { 44 + type = lib.types.port; 45 + default = 3102; 46 + description = "Port for the cursor relay WebSocket server"; 47 + }; 48 + 49 + secretsFile = lib.mkOption { 50 + type = lib.types.nullOr lib.types.path; 51 + default = null; 52 + description = "Path to secrets file (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, JWT_SECRET)"; 53 + }; 54 + 55 + deploy = { 56 + repository = lib.mkOption { 57 + type = lib.types.str; 58 + default = "https://github.com/taciturnaxolotl/CedarLogic"; 59 + description = "Git repository URL"; 60 + }; 61 + 62 + branch = lib.mkOption { 63 + type = lib.types.str; 64 + default = "web"; 65 + description = "Git branch to deploy"; 66 + }; 67 + }; 68 + }; 69 + 70 + config = lib.mkIf cfg.enable { 71 + # User and group 72 + users.groups.services = {}; 73 + 74 + users.users.cedarlogic = { 75 + isSystemUser = true; 76 + group = "cedarlogic"; 77 + extraGroups = [ "services" ]; 78 + home = cfg.dataDir; 79 + createHome = true; 80 + shell = pkgs.bash; 81 + }; 82 + 83 + users.groups.cedarlogic = {}; 84 + 85 + # Caddy needs to read static files from the dist directory 86 + users.users.caddy.extraGroups = [ "cedarlogic" "services" ]; 87 + 88 + # Allow cedarlogic user to restart its own service (for SSH deploys) 89 + security.sudo.extraRules = [ 90 + { 91 + users = [ "cedarlogic" ]; 92 + commands = [ 93 + { 94 + command = "/run/current-system/sw/bin/systemctl restart cedarlogic.service"; 95 + options = [ "NOPASSWD" ]; 96 + } 97 + ]; 98 + } 99 + ]; 100 + 101 + # Systemd service 102 + systemd.services.cedarlogic = { 103 + description = "CedarLogic circuit simulator"; 104 + wantedBy = [ "multi-user.target" ]; 105 + after = [ "network.target" ]; 106 + path = [ pkgs.git pkgs.openssh pkgs.unstable.bun ]; 107 + 108 + preStart = '' 109 + set -e 110 + 111 + # Clone if not present 112 + if [ ! -d ${appDir}/.git ]; then 113 + ${pkgs.git}/bin/git clone -b ${cfg.deploy.branch} ${cfg.deploy.repository} ${appDir} 114 + fi 115 + 116 + cd ${webDir} 117 + 118 + # Install dependencies 119 + if [ -f package.json ]; then 120 + ${pkgs.unstable.bun}/bin/bun install 121 + fi 122 + 123 + # Generate gate definitions from XML 124 + ${pkgs.unstable.bun}/bin/bun run parse-gates 125 + 126 + # Build client (Vite) 127 + ${pkgs.unstable.bun}/bin/bun run build 128 + ''; 129 + 130 + serviceConfig = { 131 + Type = "exec"; 132 + User = "cedarlogic"; 133 + Group = "cedarlogic"; 134 + # Don't set WorkingDirectory — preStart needs to run before 135 + # the repo is cloned, and systemd applies it to all stages. 136 + # Instead, ExecStart cd's into webDir. 137 + EnvironmentFile = lib.mkIf (cfg.secretsFile != null) cfg.secretsFile; 138 + Environment = [ 139 + "NODE_ENV=production" 140 + "PORT=${toString cfg.port}" 141 + "WS_PORT=${toString cfg.wsPort}" 142 + "CURSOR_PORT=${toString cfg.cursorPort}" 143 + "DATABASE_PATH=${cfg.dataDir}/data/cedarlogic.db" 144 + "GOOGLE_REDIRECT_URI=https://${cfg.domain}/auth/google/callback" 145 + ]; 146 + ExecStart = "${pkgs.bash}/bin/bash -c 'cd ${webDir} && exec ${pkgs.unstable.bun}/bin/bun run src/server/index.ts'"; 147 + Restart = "on-failure"; 148 + RestartSec = "10s"; 149 + TimeoutStartSec = "120s"; 150 + 151 + StateDirectory = "cedarlogic"; 152 + StateDirectoryMode = "0755"; 153 + 154 + UMask = "0022"; 155 + NoNewPrivileges = true; 156 + ProtectSystem = "strict"; 157 + ProtectHome = true; 158 + PrivateTmp = true; 159 + }; 160 + 161 + serviceConfig.ExecStartPre = [ 162 + "!${pkgs.writeShellScript "cedarlogic-setup" '' 163 + mkdir -p ${webDir} 164 + mkdir -p ${cfg.dataDir}/data 165 + chown -R cedarlogic:services ${cfg.dataDir} 166 + chmod -R g+rwX ${cfg.dataDir} 167 + ''}" 168 + ]; 169 + }; 170 + 171 + systemd.tmpfiles.rules = [ 172 + "d ${appDir} 0755 cedarlogic services -" 173 + "d ${cfg.dataDir}/data 0755 cedarlogic services -" 174 + ]; 175 + 176 + # Caddy - path-based routing to 3 backends + static file serving 177 + services.caddy.virtualHosts.${cfg.domain} = { 178 + extraConfig = '' 179 + tls { 180 + dns cloudflare {env.CLOUDFLARE_API_TOKEN} 181 + } 182 + header { 183 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 184 + } 185 + 186 + # Hocuspocus WebSocket (Yjs collaboration) 187 + handle /ws { 188 + reverse_proxy localhost:${toString cfg.wsPort} 189 + } 190 + 191 + # Cursor relay WebSocket 192 + handle /cursor-ws { 193 + reverse_proxy localhost:${toString cfg.cursorPort} 194 + } 195 + 196 + # API and auth routes 197 + handle /api/* { 198 + reverse_proxy localhost:${toString cfg.port} 199 + } 200 + handle /auth/* { 201 + reverse_proxy localhost:${toString cfg.port} 202 + } 203 + 204 + # Static files (Vite build output + WASM) 205 + handle { 206 + root * ${webDir}/dist 207 + try_files {path} /index.html 208 + file_server 209 + } 210 + ''; 211 + }; 212 + 213 + # Backup config 214 + atelier.backup.services.cedarlogic = { 215 + paths = [ "${cfg.dataDir}/data" ]; 216 + exclude = [ "*.log" ]; 217 + preBackup = "systemctl stop cedarlogic"; 218 + postBackup = "systemctl start cedarlogic"; 219 + }; 220 + }; 221 + }
secrets/cedarlogic.age

This is a binary file and will not be displayed.

+3
secrets/secrets.nix
··· 88 88 "canvas-mcp-dkim.age".publicKeys = [ 89 89 kierank 90 90 ]; 91 + "cedarlogic.age".publicKeys = [ 92 + kierank 93 + ]; 91 94 }