OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      62d0655b3d69
DATE      2026-07-18
SUBJECT   nix: site-builder nixos module, push-driven deploys with /git mirrors
FILES     2 CHANGED
HASH      62d0655b3d69
DATE      2026-07-18
SUBJECT   nix: site-builder nixos module,
          push-driven deploys with /git mirrors
FILES     2 CHANGED
 

diff --git a/nix/default.nix b/nix/default.nix
index 96696e7..7c776a8 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -2,4 +2,9 @@
   imports = [
     ./shell.nix
   ];
+
+  flake.nixosModules = {
+    ooknet-site = import ./module.nix;
+    default = import ./module.nix;
+  };
 }

diff --git a/nix/default.nix b/nix/default.n
ix
index 96696e7..7c776a8 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -2,4 +2,9 @@
   imports = [
     ./shell.nix
   ];
+
+  flake.nixosModules = {
+    ooknet-site = import ./module.nix;
+    default = import ./module.nix;
+  };
 }
 

diff --git a/nix/module.nix b/nix/module.nix
new file mode 100644
index 0000000..58bec51
--- /dev/null
+++ b/nix/module.nix
@@ -0,0 +1,275 @@
+# ooknet.org site builder - push-driven static deployment.
+#
+# The site is NOT a nix package: /git pages read real repositories at
+# build time, which the sandbox can't see. This module provisions a
+# builder instead: a oneshot unit that pulls the site source, updates
+# bare mirrors of the /git repos, runs the astro build with
+# OOKNET_GIT_ROOT pointing at those mirrors, and atomically swaps a
+# `current` symlink. A localhost webhook socket (point a forgejo
+# system webhook at it) and a daily timer trigger it.
+#
+# Consumer wiring (ooknet flake):
+#
+#   inputs.ooknet-org.url = "git+https://git.ooknet.org/ooks/ooknet.org";
+#   imports = [ inputs.ooknet-org.nixosModules.ooknet-site ];
+#
+#   services.ooknet-site = {
+#     enable = true;
+#     mirrors = [
+#       { slug = "ooknet-org";   url = "http://localhost:3000/ooks/ooknet.org.git
"; }
+#       { slug = "ooknet";      url = "http://localhost:3000/ooks/ooknet.git"; }
+#       { slug = "wowsim-stats"; url = "http://localhost:3000/ooks/wowsim-stats.g
it"; }
+#     ];
+#   };
+#
+#   services.caddy.virtualHosts."ooknet.org".extraConfig = ''
+#     root * ${config.services.ooknet-site.webroot}
+#     file_server
+#   '';
+#
+# Failed builds never touch the symlink, so a broken push can't take
+# the site down.
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}: let
+  inherit (lib) mkEnableOption mkOption mkIf types concatMapStringsSep escapeShel
lArg;
+
+  cfg = config.services.ooknet-site;
+
+  buildScript = pkgs.writeShellApplication {
+    name = "ooknet-site-build";
+    runtimeInputs = [pkgs.git cfg.nodejs pkgs.coreutils pkgs.findutils];
+    text = ''
+      state=${escapeShellArg cfg.stateDir}
+      src="$state/src"
+      export HOME="$state"
+      export npm_config_cache="$state/npm-cache"
+
+      # site source
+      if [ ! -d "$src/.git" ]; then
+        git clone --branch ${escapeShellArg cfg.branch} ${escapeShellArg cfg.site
Repo} "$src"
+      fi
+      git -C "$src" fetch origin ${escapeShellArg cfg.branch}
+      git -C "$src" reset --hard origin/${escapeShellArg cfg.branch}
+
+      # bare mirrors for the /git pages
+      mkdir -p "$state/mirrors"
+      ${concatMapStringsSep "\n" (m: ''
+        if [ ! -d "$state/mirrors/${m.slug}.git" ]; then
+          git clone --mirror ${escapeShellArg m.url} "$state/mirrors/${m.slug}.gi
t"
+        else
+          git -C "$state/mirrors/${m.slug}.git" remote update --prune
+        fi
+      '') cfg.mirrors}
+
+      # build
+      cd "$src/${cfg.siteSubdir}"
+      npm ci --no-audit --no-fund
+      OOKNET_GIT_ROOT="$state/mirrors" npm run build
+
+      # publish: timestamped dir, atomic symlink swap, prune old builds
+      ts=$(date +%Y%m%d-%H%M%S)
+      mkdir -p "$state/builds"
+      cp -r dist "$state/builds/$ts"
+      chmod -R a+rX "$state/builds/$ts"
+      ln -sfn "builds/$ts" "$state/current.tmp"
+      mv -T "$state/current.tmp" "$state/current"
+      find "$state/builds" -mindepth 1 -maxdepth 1 -type d \
+        | sort | head -n -${toString cfg.keepBuilds} | xargs -r rm -rf
+    '';
+  };
+
+  # socket-activated per-connection handler: drain the request, answer
+  # 204, poke the trigger file the path unit watches. Localhost only;
+  # forgejo is on the same host, so no secret handshake is needed.
+  webhookHandler = pkgs.writeShellApplication {
+    name = "ooknet-site-webhook";
+    runtimeInputs = [pkgs.coreutils];
+    text = ''
+      length=0
+      while IFS= read -r line; do
+        line=''${line%$'\r'}
+        [ -z "$line" ] && break
+        case "$line" in
+          [Cc]ontent-[Ll]ength:*) length=''${line##*: } ;;
+        esac
+      done
+      [ "$length" -gt 0 ] 2>/dev/null && head -c "$length" > /dev/null
+      printf 'HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n'
+      date +%s%N > ${escapeShellArg cfg.stateDir}/trigger
+    '';
+  };
+in {
+  options.services.ooknet-site = {
+    enable = mkEnableOption "the ooknet.org site builder";
+
+    siteRepo = mkOption {
+      type = types.str;
+      default = "http://localhost:3000/ooks/ooknet.org.git";
+      description = "Clone url of the site repository.";
+    };
+
+    branch = mkOption {
+      type = types.str;
+      default = "master";
+      description = "Branch the site deploys from.";
+    };
+
+    siteSubdir = mkOption {
+      type = types.str;
+      default = "ooknet-design";
+      description = "Subdirectory of the site repo holding package.json.";
+    };
+
+    mirrors = mkOption {
+      type = types.listOf (types.submodule {
+        options = {
+          slug = mkOption {
+            type = types.str;
+            description = "Mirror name; must match a slug in the site's git.confi
g.ts.";
+          };
+          url = mkOption {
+            type = types.str;
+            description = "Clone url, typically anonymous http from local forgejo
.";
+          };
+        };
+      });
+      default = [];
+      description = "Repositories rendered by the site's /git pages.";
+    };
+
+    nodejs = mkOption {
+      type = types.package;
+      default = pkgs.nodejs;
+      description = "Node used for the build; keep in step with the site dev shel
l.";
+    };
+
+    stateDir = mkOption {
+      type = types.path;
+      default = "/var/lib/ooknet-site";
+      description = "Builder state: source checkout, mirrors, npm cache, builds."
;
+    };
+
+    webroot = mkOption {
+      type = types.path;
+      readOnly = true;
+      default = "${cfg.stateDir}/current";
+      description = "Point the webserver root here; a symlink to the last good bu
ild.";
+    };
+
+    webhook = {
+      enable = mkOption {
+        type = types.bool;
+        default = true;
+        description = "Listen for forgejo push webhooks on localhost.";
+      };
+      address = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Bind address. Keep it loopback; there is no auth.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 9473;
+        description = "Webhook port; forgejo posts to http://address:port/.";
+      };
+    };
+
+    timer = mkOption {
+      type = types.nullOr types.str;
+      default = "daily";
+      description = "systemd OnCalendar backstop rebuild; null disables.";
+    };
+
+    keepBuilds = mkOption {
+      type = types.ints.positive;
+      default = 3;
+      description = "Previous builds retained for rollback.";
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "ooknet-site";
+      description = "Builder user.";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "ooknet-site";
+      description = "Builder group.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      inherit (cfg) group;
+      home = cfg.stateDir;
+    };
+    users.groups.${cfg.group} = {};
+
+    systemd.tmpfiles.rules = [
+      # 0755 so the webserver can follow current/ into builds/
+      "d ${cfg.stateDir} 0755 ${cfg.user} ${cfg.group} - -"
+      "f ${cfg.stateDir}/trigger 0644 ${cfg.user} ${cfg.group} - -"
+    ];
+
+    systemd.services.ooknet-site-build = {
+      description = "ooknet.org site build";
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+        ExecStart = lib.getExe buildScript;
+        WorkingDirectory = cfg.stateDir;
+        TimeoutStartSec = "15min";
+        # coalesce push bursts before the expensive part starts
+        ExecStartPre = "${pkgs.coreutils}/bin/sleep 3";
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+      };
+    };
+
+    systemd.timers.ooknet-site-build = mkIf (cfg.timer != null) {
+      description = "ooknet.org site rebuild backstop";
+      wantedBy = ["timers.target"];
+      timerConfig = {
+        OnCalendar = cfg.timer;
+        Persistent = true;
+        RandomizedDelaySec = "10m";
+      };
+    };
+
+    systemd.paths.ooknet-site-trigger = mkIf cfg.webhook.enable {
+      description = "ooknet.org rebuild trigger";
+      wantedBy = ["paths.target"];
+      pathConfig = {
+        PathModified = "${cfg.stateDir}/trigger";
+        Unit = "ooknet-site-build.service";
+      };
+    };
+
+    systemd.sockets.ooknet-site-webhook = mkIf cfg.webhook.enable {
+      description = "ooknet.org webhook socket";
+      wantedBy = ["sockets.target"];
+      listenStreams = ["${cfg.webhook.address}:${toString cfg.webhook.port}"];
+      socketConfig.Accept = true;
+    };
+
+    systemd.services."ooknet-site-webhook@" = mkIf cfg.webhook.enable {
+      description = "ooknet.org webhook handler";
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        StandardInput = "socket";
+        StandardOutput = "socket";
+        ExecStart = lib.getExe webhookHandler;
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+      };
+    };
+  };
+}

diff --git a/nix/module.nix b/nix/module.nix
new file mode 100644
index 0000000..58bec51
--- /dev/null
+++ b/nix/module.nix
@@ -0,0 +1,275 @@
+# ooknet.org site builder - push-driven sta
tic deployment.
+#
+# The site is NOT a nix package: /git pages
 read real repositories at
+# build time, which the sandbox can't see. 
This module provisions a
+# builder instead: a oneshot unit that pull
s the site source, updates
+# bare mirrors of the /git repos, runs the 
astro build with
+# OOKNET_GIT_ROOT pointing at those mirrors
, and atomically swaps a
+# `current` symlink. A localhost webhook so
cket (point a forgejo
+# system webhook at it) and a daily timer t
rigger it.
+#
+# Consumer wiring (ooknet flake):
+#
+#   inputs.ooknet-org.url = "git+https://gi
t.ooknet.org/ooks/ooknet.org";
+#   imports = [ inputs.ooknet-org.nixosModu
les.ooknet-site ];
+#
+#   services.ooknet-site = {
+#     enable = true;
+#     mirrors = [
+#       { slug = "ooknet-org";   url = "htt
p://localhost:3000/ooks/ooknet.org.git"; }
+#       { slug = "ooknet";      url = "http
://localhost:3000/ooks/ooknet.git"; }
+#       { slug = "wowsim-stats"; url = "htt
p://localhost:3000/ooks/wowsim-stats.git"; }
+#     ];
+#   };
+#
+#   services.caddy.virtualHosts."ooknet.org
".extraConfig = ''
+#     root * ${config.services.ooknet-site.
webroot}
+#     file_server
+#   '';
+#
+# Failed builds never touch the symlink, so
 a broken push can't take
+# the site down.
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}: let
+  inherit (lib) mkEnableOption mkOption mkI
f types concatMapStringsSep escapeShellArg;
+
+  cfg = config.services.ooknet-site;
+
+  buildScript = pkgs.writeShellApplication 
{
+    name = "ooknet-site-build";
+    runtimeInputs = [pkgs.git cfg.nodejs pk
gs.coreutils pkgs.findutils];
+    text = ''
+      state=${escapeShellArg cfg.stateDir}
+      src="$state/src"
+      export HOME="$state"
+      export npm_config_cache="$state/npm-c
ache"
+
+      # site source
+      if [ ! -d "$src/.git" ]; then
+        git clone --branch ${escapeShellArg
 cfg.branch} ${escapeShellArg cfg.siteRepo} 
"$src"
+      fi
+      git -C "$src" fetch origin ${escapeSh
ellArg cfg.branch}
+      git -C "$src" reset --hard origin/${e
scapeShellArg cfg.branch}
+
+      # bare mirrors for the /git pages
+      mkdir -p "$state/mirrors"
+      ${concatMapStringsSep "\n" (m: ''
+        if [ ! -d "$state/mirrors/${m.slug}
.git" ]; then
+          git clone --mirror ${escapeShellA
rg m.url} "$state/mirrors/${m.slug}.git"
+        else
+          git -C "$state/mirrors/${m.slug}.
git" remote update --prune
+        fi
+      '') cfg.mirrors}
+
+      # build
+      cd "$src/${cfg.siteSubdir}"
+      npm ci --no-audit --no-fund
+      OOKNET_GIT_ROOT="$state/mirrors" npm 
run build
+
+      # publish: timestamped dir, atomic sy
mlink swap, prune old builds
+      ts=$(date +%Y%m%d-%H%M%S)
+      mkdir -p "$state/builds"
+      cp -r dist "$state/builds/$ts"
+      chmod -R a+rX "$state/builds/$ts"
+      ln -sfn "builds/$ts" "$state/current.
tmp"
+      mv -T "$state/current.tmp" "$state/cu
rrent"
+      find "$state/builds" -mindepth 1 -max
depth 1 -type d \
+        | sort | head -n -${toString cfg.ke
epBuilds} | xargs -r rm -rf
+    '';
+  };
+
+  # socket-activated per-connection handler
: drain the request, answer
+  # 204, poke the trigger file the path uni
t watches. Localhost only;
+  # forgejo is on the same host, so no secr
et handshake is needed.
+  webhookHandler = pkgs.writeShellApplicati
on {
+    name = "ooknet-site-webhook";
+    runtimeInputs = [pkgs.coreutils];
+    text = ''
+      length=0
+      while IFS= read -r line; do
+        line=''${line%$'\r'}
+        [ -z "$line" ] && break
+        case "$line" in
+          [Cc]ontent-[Ll]ength:*) length=''
${line##*: } ;;
+        esac
+      done
+      [ "$length" -gt 0 ] 2>/dev/null && he
ad -c "$length" > /dev/null
+      printf 'HTTP/1.1 204 No Content\r\nCo
nnection: close\r\n\r\n'
+      date +%s%N > ${escapeShellArg cfg.sta
teDir}/trigger
+    '';
+  };
+in {
+  options.services.ooknet-site = {
+    enable = mkEnableOption "the ooknet.org
 site builder";
+
+    siteRepo = mkOption {
+      type = types.str;
+      default = "http://localhost:3000/ooks
/ooknet.org.git";
+      description = "Clone url of the site 
repository.";
+    };
+
+    branch = mkOption {
+      type = types.str;
+      default = "master";
+      description = "Branch the site deploy
s from.";
+    };
+
+    siteSubdir = mkOption {
+      type = types.str;
+      default = "ooknet-design";
+      description = "Subdirectory of the si
te repo holding package.json.";
+    };
+
+    mirrors = mkOption {
+      type = types.listOf (types.submodule 
{
+        options = {
+          slug = mkOption {
+            type = types.str;
+            description = "Mirror name; mus
t match a slug in the site's git.config.ts."
;
+          };
+          url = mkOption {
+            type = types.str;
+            description = "Clone url, typic
ally anonymous http from local forgejo.";
+          };
+        };
+      });
+      default = [];
+      description = "Repositories rendered 
by the site's /git pages.";
+    };
+
+    nodejs = mkOption {
+      type = types.package;
+      default = pkgs.nodejs;
+      description = "Node used for the buil
d; keep in step with the site dev shell.";
+    };
+
+    stateDir = mkOption {
+      type = types.path;
+      default = "/var/lib/ooknet-site";
+      description = "Builder state: source 
checkout, mirrors, npm cache, builds.";
+    };
+
+    webroot = mkOption {
+      type = types.path;
+      readOnly = true;
+      default = "${cfg.stateDir}/current";
+      description = "Point the webserver ro
ot here; a symlink to the last good build.";
+    };
+
+    webhook = {
+      enable = mkOption {
+        type = types.bool;
+        default = true;
+        description = "Listen for forgejo p
ush webhooks on localhost.";
+      };
+      address = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Bind address. Keep i
t loopback; there is no auth.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 9473;
+        description = "Webhook port; forgej
o posts to http://address:port/.";
+      };
+    };
+
+    timer = mkOption {
+      type = types.nullOr types.str;
+      default = "daily";
+      description = "systemd OnCalendar bac
kstop rebuild; null disables.";
+    };
+
+    keepBuilds = mkOption {
+      type = types.ints.positive;
+      default = 3;
+      description = "Previous builds retain
ed for rollback.";
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "ooknet-site";
+      description = "Builder user.";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "ooknet-site";
+      description = "Builder group.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      inherit (cfg) group;
+      home = cfg.stateDir;
+    };
+    users.groups.${cfg.group} = {};
+
+    systemd.tmpfiles.rules = [
+      # 0755 so the webserver can follow cu
rrent/ into builds/
+      "d ${cfg.stateDir} 0755 ${cfg.user} $
{cfg.group} - -"
+      "f ${cfg.stateDir}/trigger 0644 ${cfg
.user} ${cfg.group} - -"
+    ];
+
+    systemd.services.ooknet-site-build = {
+      description = "ooknet.org site build"
;
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+        ExecStart = lib.getExe buildScript;
+        WorkingDirectory = cfg.stateDir;
+        TimeoutStartSec = "15min";
+        # coalesce push bursts before the e
xpensive part starts
+        ExecStartPre = "${pkgs.coreutils}/b
in/sleep 3";
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+      };
+    };
+
+    systemd.timers.ooknet-site-build = mkIf
 (cfg.timer != null) {
+      description = "ooknet.org site rebuil
d backstop";
+      wantedBy = ["timers.target"];
+      timerConfig = {
+        OnCalendar = cfg.timer;
+        Persistent = true;
+        RandomizedDelaySec = "10m";
+      };
+    };
+
+    systemd.paths.ooknet-site-trigger = mkI
f cfg.webhook.enable {
+      description = "ooknet.org rebuild tri
gger";
+      wantedBy = ["paths.target"];
+      pathConfig = {
+        PathModified = "${cfg.stateDir}/tri
gger";
+        Unit = "ooknet-site-build.service";
+      };
+    };
+
+    systemd.sockets.ooknet-site-webhook = m
kIf cfg.webhook.enable {
+      description = "ooknet.org webhook soc
ket";
+      wantedBy = ["sockets.target"];
+      listenStreams = ["${cfg.webhook.addre
ss}:${toString cfg.webhook.port}"];
+      socketConfig.Accept = true;
+    };
+
+    systemd.services."ooknet-site-webhook@"
 = mkIf cfg.webhook.enable {
+      description = "ooknet.org webhook han
dler";
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        StandardInput = "socket";
+        StandardOutput = "socket";
+        ExecStart = lib.getExe webhookHandl
er;
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+      };
+    };
+  };
+}
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET