{ pkgs, lib, unstablePkgs, ... }: let rev = "3b45cc1233f11a40a756b371c9e2813c78a1ca82"; apiRev = "a73f4c4be840444072b0a7f5458438a34b470ea9"; # Private repositories are fetched over SSH (port 2222). nix-daemon runs as # root and uses /root/.ssh (identity materialized by the git-ssh-key unit), # so the source ends up in the store without any Nix-native credentials. src = builtins.fetchGit { url = "ssh://git@git.severijnse.eu:2222/jory/virtualcam-website.git"; rev = rev; }; apiSrc = builtins.fetchGit { url = "ssh://git@git.severijnse.eu:2222/jory/virtualcam-api.git"; rev = apiRev; }; # Build the Next.js app entirely in Nix (offline npm deps from the lockfile). app = unstablePkgs.buildNpmPackage { pname = "virtualcam-website"; version = "0.1.0"; src = src; npmDepsHash = "sha256-0g98Jh/RwoicjrfiSbfqNo331k3ab8hINjV6dHHN0y4="; nodejs = unstablePkgs.nodejs; buildPhase = '' runHook preBuild npx prisma generate npm run build runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out cp -r .next node_modules public prisma src package.json package-lock.json \ prisma.config.ts next.config.ts tsconfig.json postcss.config.mjs $out/ runHook postInstall ''; APP_URL = "https://virtualcam.severijnse.eu"; # Only used to satisfy prisma generate / next build metadata resolution. DATABASE_URL = "postgresql://virtualcam@localhost/virtualcam?host=/run/postgresql&schema=public"; # Use the nixpkgs-bundled Prisma engine so the sandboxed offline build does # not need to download it from binaries.prisma.sh. Version matches ^7.8.0. PRISMA_SCHEMA_ENGINE_BINARY = "${unstablePkgs.prisma-engines}/bin/schema-engine"; NEXT_TELEMETRY_DISABLED = "1"; CI = "true"; }; # License validation / admin API (Go/Fiber), serving on host port 3004. # go.mod demands go 1.26.5 but the pinned nixpkgs only has 1.26.4; the code # uses nothing newer, so the directive is relaxed to match the toolchain. api = unstablePkgs.buildGoModule { pname = "virtualcam-api"; version = "0.1.0"; src = apiSrc; vendorHash = "sha256-uvHClXHw9ycoIf6qBZmV2O3CSyIxCgnSPCSALM07qg8="; go = unstablePkgs.go_1_26; postPatch = '' sed -i 's/^go 1\.26\.5$/go 1.26.4/' go.mod ''; }; dbUrl = "postgresql://virtualcam@localhost/virtualcam?host=/run/postgresql&schema=public"; # lib/pq parses the DSN differently from node-postgres: a hostname in the URL # authority wins over a `host=` query param (so it would go over TCP and fail # password auth), and lib/pq rejects unknown URL params like `schema`. Use a # keyword DSN: unix-socket + peer auth + no SSL, matching the OS user. apiDbUrl = "host=/run/postgresql user=virtualcam dbname=virtualcam sslmode=disable"; # Root-only runtime environment file holding the secrets both services need. secretsFile = ../../secrets/secrets.yaml; envFile = "/var/lib/virtualcam/environment"; # Materialize the license signing key and admin token from sops into a # root-only file (0600). systemd reads environmentFiles before dropping # privileges, so the service users never need to read it themselves. writeSecrets = pkgs.writeShellScript "virtualcam-write-secrets" '' set -euo pipefail install -d -o virtualcam -g virtualcam -m 0750 /var/lib/virtualcam : > "${envFile}" chmod 0600 "${envFile}" ${pkgs.sops}/bin/sops --decrypt --input-type yaml --output-type yaml ${secretsFile} | ${pkgs.gnused}/bin/sed -nE \ 's/^virtualcam_license_signing_key: (.*)/LICENSE_SIGNING_KEY=\1/p; s/^virtualcam_admin_token: (.*)/ADMIN_TOKEN=\1/p; s/^virtualcam_smtp_pass: (.*)/SMTP_PASS=\1/p' \ >> "${envFile}" ''; # Materialize the nix-daemon's SSH identity so private-repo fetches keep # working after the one-off bootstrap copy in /root/.ssh. writeGitSshKey = pkgs.writeShellScript "git-ssh-key" '' set -euo pipefail install -d -m 0700 /root/.ssh ${pkgs.sops}/bin/sops --decrypt --extract '["git_ssh_key_b64"]' \ --input-type yaml --output-type yaml ${secretsFile} | ${pkgs.coreutils}/bin/base64 -d > /root/.ssh/id_ed25519 chmod 0600 /root/.ssh/id_ed25519 cat > /root/.ssh/known_hosts <<'EOF' git.severijnse.eu ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAzIVo9Jdp8kwnWmTn26Fj68baJjwDphYw/0HTH5BzYY [git.severijnse.eu]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDGcPKlvdxApYRCZuQxrljEdETrxAu0JsEJ+28zFaMfA3P69J9vEkqqAZZ0Uq3rtE8ZqPBxeDh7JYn2XChb+QVGjOHVWMCWqcdD43ws26eY91aHGYnO2kW+sKSxIh803tdifIp2q8VRnaHVXcLHLx2rmwmvqIfMLnEEShxAt4Q846DianBoA7S8heoBvMLi8AAO09sVEgMPWm8ULkoRq0poHslsKkdYKlWzecP85tPo/tlCEjQq5cC5+xidH9TS4ILxVIXGUfErZzbWZq5LtaRmqiU5U1+zzdb83oJvHFtTZ8wHwZX+VE4jZzKw7wdFPk+dJZp/3ZAVva6fLSjlpyyDFNWMcf2/18Gs6YioD1n2fr1dXruZiXNMnI19Dzyp+8yWIPbAeWyVx3BoCrcjIApEDMLMo7ut0l6RfNy80hYpY91VBrpsjSRLB4uCVDsqhTLPTrFUlu+9YY9lSHEUVLRylOLK54TbY089hbHOB/UdpyCiXpI0ds3ww1UmnDQSFjwNknc+wTXRvzEz+VsX2nAzfO+5NWq2jHNn4wH6GuAinAxSVg1A7Ub9VSmPelRiTzK6ZnplPm762MxcPFmo+y/5cszumUwypcyTS8MJIC57S/kabfsnjdt97K+9eT9X2LDZ2uIQgDS8qEgvbnvfXVmMoqakgBLYG7Embqc8BDzNsQ== EOF chmod 0600 /root/.ssh/known_hosts test -f /root/.ssh/id_ed25519.pub || ${pkgs.openssh}/bin/ssh-keygen -y -f /root/.ssh/id_ed25519 > /root/.ssh/id_ed25519.pub ''; # The repo's seed uses tsx (a devDependency buildNpmPackage drops) plus the # "@/..." path alias. nixpkgs ships tsx, which honours tsconfig paths, so we # add it to the service path rather than working around the missing dep. seed = pkgs.writeShellScript "virtualcam-seed" '' set -euo pipefail export DATABASE_URL="${dbUrl}" export PRISMA_SCHEMA_ENGINE_BINARY="${unstablePkgs.prisma-engines}/bin/schema-engine" export HOME=/var/lib/virtualcam cd ${app} tsx prisma/seed.ts ''; # One shared PostgreSQL server (existing system postgres). Each service gets # its own database + role. virtualcam authenticates over the Unix socket via # peer auth: the systemd services run as OS user `virtualcam`, which matches # the database role `virtualcam`, so no password is stored anywhere. migrate = pkgs.writeShellScript "virtualcam-migrate" '' set -euo pipefail export DATABASE_URL="${dbUrl}" # Use the local Prisma engine; no network download needed at runtime. export PRISMA_SCHEMA_ENGINE_BINARY="${unstablePkgs.prisma-engines}/bin/schema-engine" cd ${app} ./node_modules/.bin/prisma migrate deploy ''; in { users = { users.virtualcam = { isSystemUser = true; group = "virtualcam"; description = "virtualcamera services user"; }; groups.virtualcam = {}; }; services.postgresql = { ensureDatabases = ["virtualcam"]; ensureUsers = [ { name = "virtualcam"; ensureDBOwnership = true; } ]; }; systemd = { services = { git-ssh-key = { description = "Materialize nix-daemon git SSH key from sops"; wantedBy = ["multi-user.target"]; environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt"; serviceConfig = { Type = "oneshot"; Environment = ["HOME=/root"]; ExecStart = "${writeGitSshKey}"; }; }; virtualcam-secrets = { description = "Materialize virtualcam secrets from sops"; wantedBy = ["multi-user.target"]; # The age key lives in /etc/age/keys.txt; the service must know where it # is and needs a HOME for age to report its user config directory. environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt"; serviceConfig = { Type = "oneshot"; Environment = ["HOME=/root"]; ExecStart = "${writeSecrets}"; }; }; virtualcam-migrate = { description = "Virtualcam Prisma migrations"; after = ["postgresql.service"]; requires = ["postgresql.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { Type = "oneshot"; User = "virtualcam"; Group = "virtualcam"; StateDirectory = "virtualcam"; StateDirectoryMode = "0750"; ExecStart = "${migrate}"; }; }; virtualcam-seed = { description = "Virtualcam catalog seed"; after = ["virtualcam-migrate.service"]; requires = ["virtualcam-migrate.service"]; wantedBy = ["multi-user.target"]; path = [unstablePkgs.nodejs unstablePkgs.tsx]; serviceConfig = { Type = "oneshot"; User = "virtualcam"; Group = "virtualcam"; ExecCondition = "!/var/lib/virtualcam/.seeded"; ExecStart = "${seed}"; ExecStartPost = "${pkgs.coreutils}/bin/touch /var/lib/virtualcam/.seeded"; StateDirectory = "virtualcam"; StateDirectoryMode = "0750"; }; }; virtualcam = { description = "Virtualcamera website (Next.js)"; after = ["postgresql.service" "virtualcam-migrate.service" "virtualcam-seed.service" "virtualcam-secrets.service"]; requires = ["postgresql.service" "virtualcam-migrate.service" "virtualcam-seed.service" "virtualcam-secrets.service"]; wantedBy = ["multi-user.target"]; path = [unstablePkgs.nodejs]; serviceConfig = { User = "virtualcam"; Group = "virtualcam"; WorkingDirectory = "${app}"; EnvironmentFile = [envFile]; ExecStart = "${app}/node_modules/.bin/next start -p 3001 -H 127.0.0.1"; Restart = "on-failure"; RestartSec = 5; StateDirectory = "virtualcam"; StateDirectoryMode = "0750"; Environment = [ "DATABASE_URL=${dbUrl}" "APP_URL=https://virtualcam.severijnse.eu" "PAYMENTS_MODE=shkeeper" "ADMIN_EMAILS=jory@severijnse.eu" "SMTP_HOST=mail.severijnse.eu" "SMTP_PORT=587" "SMTP_USER=no-reply@severijnse.eu" "SMTP_FROM=no-reply@severijnse.eu" "NODE_ENV=production" "NEXT_TELEMETRY_DISABLED=1" "HOME=/var/lib/virtualcam" ]; }; }; virtualcam-api = { description = "Virtualcamera license API (Go/Fiber)"; after = ["postgresql.service" "virtualcam-migrate.service" "virtualcam-secrets.service"]; requires = ["postgresql.service" "virtualcam-migrate.service" "virtualcam-secrets.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { User = "virtualcam"; Group = "virtualcam"; WorkingDirectory = "${api}"; EnvironmentFile = [envFile]; ExecStart = "${api}/bin/virtualcam-api"; Restart = "on-failure"; RestartSec = 5; StateDirectory = "virtualcam"; StateDirectoryMode = "0750"; Environment = [ "PORT=3004" "DATABASE_URL=${apiDbUrl}" "CORS_ORIGINS=https://virtualcam.severijnse.eu" "HOME=/var/lib/virtualcam" ]; }; }; }; }; # Served behind Caddy (virtualHosts wired in caddy.nix). networking.firewall.allowedTCPPorts = []; }