default.nix

  1# Role: forge (infra.rtw.run). Personal git hosting: soft-serve behind Caddy,
  2# and kilimanjaro.io, the static site pgit builds from the public repos.
  3#
  4#   infra.rtw.run         /healthz only (forge-healthz)
  5#   git.kilimanjaro.io    soft-serve's HTTP (git smart HTTP, go-get), :23232
  6#   kilimanjaro.io        /var/www/code, written by the post-receive hook
  7#   www.kilimanjaro.io    redirect to kilimanjaro.io
  8#
  9# kilimanjaro.io and www are proxied by Cloudflare; git.kilimanjaro.io points
 10# at the host.
 11#
 12#   soft-serve          SSH :23231, git daemon :9418, HTTP localhost:23232,
 13#                       data in /var/soft/data (repos, sqlite, its SSH keys)
 14#   post-receive hook   soft-serve/hooks/post-receive, linked into the data
 15#                       dir; rebuilds /var/www/code in the background
 16#   backup              daily `rclone sync` of /var/soft/data to r2:soft-serve
 17#                       (recover.sh restores it)
 18#   forge-healthz       every 5 minutes: disk, load, backup age, soft-serve;
 19#                       writes the /healthz response Caddy serves
 20#
 21# Secrets (modules/secrets.nix): rclone.conf (per host) for R2.
 22{
 23  config,
 24  lib,
 25  pkgs,
 26  pgit,
 27  ...
 28}:
 29
 30let
 31  backupScript = pkgs.writeShellScriptBin "backup" (lib.fileContents ./backup.sh);
 32  rcloneKey = config.servers.secrets."rclone.conf";
 33
 34  pgitPackage = pgit.packages.${pkgs.stdenv.hostPlatform.system}.default;
 35  rebuildSite = pkgs.writeShellApplication {
 36    name = "forge-rebuild-site";
 37    runtimeInputs = [
 38      pgitPackage
 39      pkgs.sqlite
 40    ];
 41    text = builtins.readFile ./rebuild-site.sh;
 42  };
 43  softServeConfig = ./soft-serve/config.yaml;
 44  healthz = pkgs.writeShellApplication {
 45    name = "forge-healthz";
 46    runtimeInputs = with pkgs; [
 47      coreutils
 48      gawk
 49      jq
 50      systemd
 51    ];
 52    text = builtins.readFile ./healthz.sh;
 53  };
 54
 55  securityHeaders = ''
 56    header {
 57        # HSTS: enforce HTTPS for 2 years, include subdomains, allow preload
 58        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
 59        # Prevent MIME type sniffing
 60        X-Content-Type-Options "nosniff"
 61        # Clickjacking protection
 62        X-Frame-Options "DENY"
 63        # XSS protection
 64        X-XSS-Protection "1; mode=block"
 65        # Remove server header
 66        -Server
 67    }
 68  '';
 69in
 70{
 71  servers.secrets."rclone.conf".scope = "host";
 72
 73  environment.systemPackages = with pkgs; [
 74    soft-serve
 75    rclone
 76    sqlite
 77    tree
 78    pgitPackage
 79    backupScript
 80    # Called by the post-receive hook; also run it after a restore.
 81    rebuildSite
 82  ];
 83
 84  systemd.tmpfiles.rules = [
 85    "d /var/soft 0755 root root -"
 86    "d /var/www/code 0755 root root -"
 87  ];
 88
 89  # Symlink hooks directory so soft-serve reads directly from /etc/soft-serve/hooks
 90  # This eliminates the need for copying and avoids path unit infinite loops
 91  system.activationScripts.soft-serve-hooks = ''
 92    if [ -d /var/soft/data ]; then
 93      # Remove old copied hooks directory if it exists (not a symlink)
 94      if [ -d /var/soft/data/hooks ] && [ ! -L /var/soft/data/hooks ]; then
 95        rm -rf /var/soft/data/hooks
 96      fi
 97
 98      # Create symlink if it doesn't exist
 99      if [ ! -L /var/soft/data/hooks ]; then
100        ln -sf /etc/soft-serve/hooks /var/soft/data/hooks
101      fi
102    fi
103  '';
104
105  environment.etc = {
106    "soft-serve/config.yaml".source = softServeConfig;
107    "soft-serve/hooks/post-receive" = {
108      source = ./soft-serve/hooks/post-receive;
109      mode = "0755";
110    };
111  };
112
113  systemd.services.soft-serve = {
114    description = "Soft Serve - A tasty, self-hosted Git server";
115    after = [ "network.target" ];
116    wantedBy = [ "multi-user.target" ];
117    # soft-serve reads its config only at startup.
118    restartTriggers = [ softServeConfig ];
119
120    environment = {
121      SOFT_SERVE_CONFIG_LOCATION = "/etc/soft-serve/config.yaml";
122      SOFT_SERVE_DATA_PATH = "/var/soft/data";
123    };
124
125    serviceConfig = {
126      Type = "simple";
127      WorkingDirectory = "/var/soft";
128      ExecStart = "${pkgs.soft-serve}/bin/soft serve";
129      Restart = "on-failure";
130      RestartSec = 5;
131      User = "root";
132      Group = "root";
133    };
134  };
135
136  systemd.services.backup = {
137    description = "Daily backup service";
138    wants = [ rcloneKey.unit ];
139    after = [ rcloneKey.unit ];
140    serviceConfig = {
141      Type = "oneshot";
142      ExecStart = "${backupScript}/bin/backup";
143      User = "root";
144      # last-success, for forge-healthz
145      StateDirectory = "forge-backup";
146    };
147    environment = {
148      RCLONE = "${pkgs.rclone}/bin/rclone";
149      RCLONE_CONFIG = rcloneKey.path;
150    };
151  };
152
153  systemd.timers.backup = {
154    description = "Daily backup timer (8:30 PM UTC)";
155    wantedBy = [ "timers.target" ];
156    timerConfig = {
157      OnCalendar = "20:30:00";
158      Persistent = true;
159    };
160  };
161
162  # The /healthz response, as a file for Caddy (see healthz.sh). Its output
163  # stays in /run after each run; the load samples persist in /var/lib.
164  systemd.services.forge-healthz = {
165    description = "Write the forge's /healthz response";
166    serviceConfig = {
167      Type = "oneshot";
168      ExecStart = lib.getExe healthz;
169      RuntimeDirectory = "forge-healthz";
170      RuntimeDirectoryPreserve = true;
171      StateDirectory = "forge-healthz";
172    };
173  };
174
175  systemd.timers.forge-healthz = {
176    description = "Write the forge's /healthz response (every 5 minutes)";
177    wantedBy = [ "timers.target" ];
178    timerConfig = {
179      OnBootSec = "1min";
180      OnUnitActiveSec = "5min";
181    };
182  };
183
184  # Interactive `rclone` as root (e.g. recover.sh) finds the same config. The
185  # file itself stays root-only.
186  environment.variables.RCLONE_CONFIG = rcloneKey.path;
187
188  # git SSH, git daemon, HTTP(S)
189  networking.firewall.allowedTCPPorts = [ 23231 9418 80 443 ];
190
191  services.caddy = {
192    enable = true;
193    # The old Caddyfile had no global options: Caddy's default logger at
194    # INFO (renewals show in the journal) and no access logs. Keep both.
195    logFormat = lib.mkForce "level INFO";
196
197    # ok.json -> 200, otherwise degraded.json -> 503, or 404 when neither
198    # exists yet. Never cached: the monitor must see each run's answer.
199    virtualHosts."infra.rtw.run" = {
200      logFormat = null;
201      extraConfig = ''
202        ${securityHeaders}
203        handle /healthz {
204            root * /run/forge-healthz
205            header Cache-Control "no-store"
206            @ok file /ok.json
207            handle @ok {
208                rewrite * /ok.json
209                file_server
210            }
211            handle {
212                rewrite * /degraded.json
213                file_server {
214                    status 503
215                }
216            }
217        }
218        handle {
219            respond 404
220        }
221      '';
222    };
223
224    virtualHosts."git.kilimanjaro.io" = {
225      logFormat = null;
226      extraConfig = ''
227        ${securityHeaders}
228        # soft-serve's HTTP server
229        reverse_proxy localhost:23232
230      '';
231    };
232
233    virtualHosts."www.kilimanjaro.io" = {
234      logFormat = null;
235      extraConfig = ''
236        redir https://kilimanjaro.io{uri} permanent
237      '';
238    };
239
240    virtualHosts."kilimanjaro.io" = {
241      logFormat = null;
242      extraConfig = ''
243        ${securityHeaders}
244        root * /var/www/code
245        encode gzip
246
247        # Strip .html extensions for clean URLs
248        try_files {path} {path}.html {path}/ =404
249
250        file_server {
251            index index.html
252        }
253
254        # 12 hour cache for most static assets
255        @notCSS not path *.css
256        header @notCSS Cache-Control "max-age=43200"
257
258        # Immutable cache for CSS files (hashed filenames)
259        @css path *.css
260        header @css Cache-Control "max-age=31536000, immutable"
261      '';
262    };
263  };
264}