From cb8835bbfb2f3e16733ec7027c51c4f6cb44c4a2 Mon Sep 17 00:00:00 2001 From: Jory Severijnse Date: Fri, 14 Aug 2026 21:34:50 +0200 Subject: [PATCH] feat(hetzner): add my api and update website --- servers/hetzner/modules/services/caddy.nix | 13 +- .../hetzner/modules/services/virtualcam.nix | 138 +++++++++++++++--- servers/hetzner/secrets/secrets.yaml | 7 +- 3 files changed, 138 insertions(+), 20 deletions(-) diff --git a/servers/hetzner/modules/services/caddy.nix b/servers/hetzner/modules/services/caddy.nix index c5b6c91..442df54 100644 --- a/servers/hetzner/modules/services/caddy.nix +++ b/servers/hetzner/modules/services/caddy.nix @@ -249,13 +249,24 @@ in { ''; }; + "api.severijnse.eu" = { + extraConfig = '' + import security_headers + # Machine-facing license API for the Android app: no antiScrape + # bot-blocking (it would drop legitimate non-browser clients) and no + # admin_gate (endpoints are protected by the bearer token). + reverse_proxy 127.0.0.1:3004 + encode zstd gzip + ''; + }; + "releases.severijnse.eu" = { extraConfig = '' import security_headers root * /srv/releases file_server browse encode zstd gzip - + handle /private* { import release_gate } diff --git a/servers/hetzner/modules/services/virtualcam.nix b/servers/hetzner/modules/services/virtualcam.nix index e9f637b..e59dcb2 100644 --- a/servers/hetzner/modules/services/virtualcam.nix +++ b/servers/hetzner/modules/services/virtualcam.nix @@ -4,27 +4,28 @@ unstablePkgs, ... }: let - rev = "6225e0fca02c02544341c92ecdc9634a9a15f45c"; - src = pkgs.fetchgit { - url = "https://git.severijnse.eu/jory/virtualcam-website.git"; + rev = "0e7135918c251ce6bcc2ead1d3f4cf0075ae2c55"; + 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; - sha256 = "17ihw2bhsp89nczljz6xzwlvxyzgsdn62ywmchp5blzd6jkxd3w0"; }; - # Patch the app to be fully dynamic and drop the Google-font download so the - # sandboxed Nix build needs neither a database nor network access. - srcPatched = pkgs.applyPatches { - name = "virtualcam-website-patched"; - src = src; - patches = [./virtualcam-layout.patch ./virtualcam-build.patch]; + 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 = srcPatched; - npmDepsHash = "sha256-52ugs4ydwxGXLIhF/6P8uO400x3BRYk4NUt2Swob3cY="; + src = src; + npmDepsHash = "sha256-0g98Jh/RwoicjrfiSbfqNo331k3ab8hINjV6dHHN0y4="; nodejs = unstablePkgs.nodejs; buildPhase = '' @@ -52,7 +53,61 @@ 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' \ + >> "${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 @@ -68,7 +123,7 @@ # 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 service runs as OS user `virtualcam`, which matches + # 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 @@ -83,7 +138,7 @@ in { users.virtualcam = { isSystemUser = true; group = "virtualcam"; - description = "virtualcamera website service user"; + description = "virtualcamera services user"; }; groups.virtualcam = {}; }; @@ -100,6 +155,30 @@ in { 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"]; @@ -135,14 +214,15 @@ in { virtualcam = { description = "Virtualcamera website (Next.js)"; - after = ["postgresql.service" "virtualcam-migrate.service" "virtualcam-seed.service"]; - requires = ["postgresql.service" "virtualcam-migrate.service" "virtualcam-seed.service"]; + 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; @@ -163,9 +243,33 @@ in { ]; }; }; + + 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" + ]; + }; + }; }; }; - # Serve behind Caddy on 127.0.0.1:3000 (virtualHost wired in caddy.nix). + # Served behind Caddy (virtualHosts wired in caddy.nix). networking.firewall.allowedTCPPorts = []; } diff --git a/servers/hetzner/secrets/secrets.yaml b/servers/hetzner/secrets/secrets.yaml index 4526a7f..72ae5d1 100644 --- a/servers/hetzner/secrets/secrets.yaml +++ b/servers/hetzner/secrets/secrets.yaml @@ -3,6 +3,9 @@ b2_key_id: ENC[AES256_GCM,data:4B9rvg06baH6aNiT,iv:Qk1ToF3lMYLTrZdzpfaoGVzdiKYs4 b2_application_key: ENC[AES256_GCM,data:xGAbBqx+6ErK7sy3FR0yza6mJU5oc5uQQGcwKtcPp1Ef4XEVd8do/wN7,iv:CojcoykDRBfvN8yqMMrPJq9mtAwxyswqXXVBKPupxDs=,tag:BkfFHAGJvtCDUpFoxXVP3Q==,type:str] stalwart_admin_hash: ENC[AES256_GCM,data:GuSL/4dVdAsPDOqzvBv/rQ/TGrKU1Enc5MEQ17R8gHdsVH7ujIHxQYMu+aSAtKW0Le/hqNT+2TwOEXPsRt2ZQO/2Ks+hg7cN0mPGg8PRIN7v1wwrQpxVBNfyuX/HzwzLW/Plxc8g4Cmf8g==,iv:HwhafxB9ek9WnA76EJ04iaLZHJ72b4PtbJYOI1eFJcU=,tag:w1M+7KWOVkPZtyyE2Uv/Zg==,type:str] wg_admin_password: ENC[AES256_GCM,data:X/+YJoNoFFU7P/2HCpTI,iv:FXDS3xuFB9jxjpQhsX2EKn0YFVzL23c2sZO6RqpKTiw=,tag:ccyQVzqNc1HEY0rxJSLlKw==,type:str] +virtualcam_license_signing_key: ENC[AES256_GCM,data:zk8YDER9d9mBOdXgEKKC6dubSMfz6UMMb758ZC9SvjkYIv+R2EVd+CJwL93MCs0l62lUDxoBDCgTLbKM55kcaThkkqfgZCYPrndXevYZ7kquNQEMkoYg/pyGAWhAQ9trvT5QXfBES9UDdYi6J9taCVKCofutiMEdoJM8olgUwKsP4i+fbt3idYkdWNGelastPnvTuUK/M3/dOKR5eheiy5oIntG+sECOz/2eKHJ8cU3ja18uAEwcpd9VojuOa0fvfui8AujQcMZNSMv8VsLGnoR854iX3g1MvHkTBSzv3qaDzRC54MueXYR4WOzm0o+yv6Otmw1n9trWtk+WLUimmiKxDdW6nSFaddeisWzy47+QYzrSsd7iovNYEIHnW4kpnT5W2g5lwaJwA0J0pBOqvV9gziv8oBNeEbLurBxi78KwHBqG,iv:svx+hC4tS26xqI05+yo6N2d3uHzO7ULM0e76dMbShjs=,tag:EJ9fjHEi1wUhLkxy8sexww==,type:str] +virtualcam_admin_token: ENC[AES256_GCM,data:JC5zSFBCoEapxv/Kcs4VXk3HAkiqwXVrCv1rlqFsXVx3LGspnQdzjOIahlM=,iv:GRVfMNGkwC3xXR00ww29rFbhTrpuViad+H05yDhR9BA=,tag:OBnxBD/8KU4mn17FFTcdng==,type:str] +git_ssh_key_b64: ENC[AES256_GCM,data:LaUcR005w9iCd/DifDhzLM7tU4AuTiFM802gMU1HMtvFN2iSffucaFJWxpKv2zFxDxz0QpA3XwM1M5yJC4K3YFMxPkPXcWLYN4yHcc1yXuoB3SseQXjcJnPMqZvJfVYhHep+6CVVlVDQs1Y+miBvbAhrvCEblwUu5LhD7yvoJLwTnEpT15WGDvqezzYKzNSyQGNRa8KPjsD/7gJN3RyUPzKo6GsrIp8d8eOTuCZwEcJVDKE0ACXs4V6cd+akqIWZkNPyilnMTuK5cqg2YuqSpwu9NncKED6SjmPiozPKKgTVaWhNCRJUTBfIw85fdXizvq7PNbNRxxBsscAWd0ixXiLqg1RTipfv85wL4Nru3r3qcQGAWmOpTHKW5fA9kR4ygdNxEVkOgQ70m9U0wev+vdIJUoRYj09Ikobej370NKppOP6+oeEDE7NpkFH7tzRk1oHtwoc1dwP9Kq1T8XdxttCKFyb1pAJ6gMdMThCfJlC9/Uu/WYbYVu5bCj6BAmrdrmhbPrsEOylBeC9j9RhyRrFEqLd8AZ01Zg8oKcj9WtCWEMf9Y4biv2xE43JbBGRvLAx3sH9MRplu2hN2W/bRxa/EPj9GHgbUjScDBkm/QVbMeV+QOgi09sCHDTeSX3BHdZ8ZLNjuNUwYnWZq4lPix0iYS5MufiixNdQmncPeHq0UuBtVifEl2Jmjq1COLyeAQe47AKQ5zDjT91xtBRZVZEVk0kGBYHtBEJ9ezPwckSw=,iv:cbIpwwhmyJVvvTMB95YBzt+RQLOji6yJh+SpcJc3Q1Y=,tag:t8WxU1IPDfkB8muTdBLUhg==,type:str] sops: kms: [] gcp_kms: [] @@ -36,8 +39,8 @@ sops: MHJrVVpDYWdJNmxtUkozSzR4Nmt3R28KrhYi830HUFAPfg8WvPad7BAuNe1mYOWt WEFIquuX/H/N+y/7uQcBDbvnBzyropE1hW8aNrxSKMeawvQZWNXkZA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-14T13:57:09Z" - mac: ENC[AES256_GCM,data:KhpVOtwk8K4ZuGm6oZ3F7IDcxcR50k6vYojGwQaqKMq1xTGvtty4Qb+ZA7ENLjUSRY28mO4lzb3Dt7iK87TjiXeDhRGDAPnWGgzmGVzkxcuvu4nBX6Bf+ZikTQLW+SiDDSqsRTfaLwTacCQd7bLz4KUzXsX3HO0Yy7NPfwNZ+Q4=,iv:EJtR1meg8bFEdXaWgwCTKFMhGJKNZHXaaKfIxWaR4ME=,tag:3RAQXuG9WBZSUUoE3s1rkw==,type:str] + lastmodified: "2026-08-14T15:10:08Z" + mac: ENC[AES256_GCM,data:8UXgsmw43v1yyLNAs9RwYDWQ7wCyFFV02S+X1mmP5iSR7GhP4lWdWqlsWC+81f9XsyZ/7jYV26NhJRMfyxhsYUcKhduKb5E8Mym8qW0FYPRMQycLAnjygocgWJGmKONjeMuURH/6hKcfvw8AIfRAUgcud5DLeSUChd61umM5xE0=,iv:jSkQhr9QxadZEKehfzM+EcyQ2O4dZIydDwbzyb+0ujI=,tag:nTHUAN1CTiDVk/MZJ/ligw==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.8.1