wip: deploy lauti-to-mobilizon on lauti.realraum.at
authorBernhard Tittelbach <bernhard@tittelbach.org>
Thu, 29 Oct 2026 01:17:56 +0000 (02:17 +0100)
committerBernhard Tittelbach <bernhard@tittelbach.org>
Thu, 29 Oct 2026 01:17:56 +0000 (02:17 +0100)
nixos/lauti.http.realraum.at/etc/nixos/configuration.nix

index f8b2e01..30c1d9c 100644 (file)
@@ -1,5 +1,142 @@
 { config, lib, pkgs, ... }:
 
+let
+  mobilizonCalendarMirror = {
+    user = "calendarbot";
+    group = "calendarbot";
+    home = "/home/calendarbot";
+    repoDir = "/home/calendarbot/lauti-to-mobilizon";
+    repository = "https://github.com/realraum/grical-to-mobilizon.git";
+    branch = "lauti-to-mobilizon";
+    fallbackBranch = "master";
+  };
+
+  nodejs = pkgs.nodejs_22 or pkgs.nodejs;
+  pnpm = pkgs.pnpm_10 or pkgs.pnpm;
+
+  mobilizonCalendarMirrorConfig = {
+    usernames = [ "realraum" ];
+    defaultPicture = "5d5efceb-8c08-436b-a906-8172c5008366";
+    addressID = "91";
+    mobilizon = "https://events.graz.social/api";
+    lauti = "https://r3.at/public_events.ics";
+    lautiHosts = [ "laut.realraum.at" ];
+    maxFutureDuration = {
+      months = 4;
+    };
+    _predefinedHint = "Can contain: { organizer, summary, picture: { mediaUuid }, title, tags (added to lauti tags) }";
+    predefined = [
+      [
+        "Mitgliedertreffen"
+        {
+          title = "realraum Mitgliedertreffen";
+          summary = "<p><strong>Worum geht’s?</strong></p><p>Der realraum ist ein Treffpunkt zum Basteln, Experimentieren und vielem mehr – ein Hackerspace für die Bereiche Software, Hardware, Computer und Elektronik, Molekularbiologie und Chemie. <a target=\"_blank\" rel=\"noopener noreferrer ugc\" href=\"https://wp.realraum.at/realraum/\">Lies mehr über uns</a></p>";
+        }
+      ]
+      [
+        "Funkfeuer"
+        {
+          organizer = "funkfeuer";
+          summary = "<p>Funkfeuer Graz ist ein freies, experimentelles Funk-Netzwerk in Graz. Funkfeuer ist offen für Jeden und Jede, der/die Interesse hat und bereit ist mitzuarbeiten. Funkfeuer ist ein nicht reguliertes Netzwerk, das den digitalen Graben zwischen den sozialen Schichten überbrückt und Infrastruktur und Wissen zur Verfügung zu stellen.</p><p>Funkfeuer wird von computerbegeisterten Menschen mit unterschiedlicher Motivation und Interessen betrieben. Das Projekt verfolgt keine kommerziellen Interessen.</p><p>Funkfeuer ist auch ein Soziales Experiment denn es versucht Arbeitsweisen aus der Open Source Welt in einem gesellschaftlichen Kontext zu etablieren.</p><p>Es gibt keine Zentrale Institution, wir bauen uns unser Netzwerk selber!</p>";
+        }
+      ]
+    ];
+  };
+
+  mobilizonCalendarMirrorConfigFile =
+    pkgs.writeText "mobilizon-calendar-mirror-config.json"
+      (builtins.toJSON mobilizonCalendarMirrorConfig);
+
+  mobilizonCalendarMirrorUpdate = pkgs.writeShellApplication {
+    name = "mobilizon-calendar-mirror-update";
+    runtimeInputs = with pkgs; [
+      binutils
+      cacert
+      coreutils
+      curl.dev
+      gcc
+      git
+      gnumake
+      nodejs
+      openssl.dev
+      pkg-config
+      pnpm
+      python3
+      zlib.dev
+    ];
+    text = ''
+      set -euo pipefail
+
+      repoDir=${lib.escapeShellArg mobilizonCalendarMirror.repoDir}
+      repository=${lib.escapeShellArg mobilizonCalendarMirror.repository}
+      branch=${lib.escapeShellArg mobilizonCalendarMirror.branch}
+      fallbackBranch=${lib.escapeShellArg mobilizonCalendarMirror.fallbackBranch}
+
+      export HOME=${lib.escapeShellArg mobilizonCalendarMirror.home}
+      export SSL_CERT_FILE=${lib.escapeShellArg "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"}
+      export GIT_SSL_CAINFO="$SSL_CERT_FILE"
+
+      # Build native npm modules (node-libcurl) against Nix-provided libraries.
+      export npm_config_nodedir=${lib.escapeShellArg "${nodejs}"}
+      export npm_config_build_from_source=true
+      export CFLAGS=${lib.escapeShellArg "-I${pkgs.curl.dev}/include"}
+      export CXXFLAGS=${lib.escapeShellArg "-I${pkgs.curl.dev}/include"}
+      export PKG_CONFIG_PATH=${lib.escapeShellArg "${pkgs.curl.dev}/lib/pkgconfig:${pkgs.openssl.dev}/lib/pkgconfig:${pkgs.zlib.dev}/lib/pkgconfig"}
+      export NIX_CFLAGS_COMPILE=${lib.escapeShellArg "-I${pkgs.curl.dev}/include -I${pkgs.openssl.dev}/include -I${pkgs.zlib.dev}/include"}
+      linkFlags=${lib.escapeShellArg "-L${pkgs.curl.out}/lib -L${pkgs.openssl.out}/lib -L${pkgs.zlib.out}/lib -Wl,-rpath,${pkgs.curl.out}/lib -Wl,-rpath,${pkgs.openssl.out}/lib -Wl,-rpath,${pkgs.zlib.out}/lib"}
+      export NIX_LDFLAGS="$linkFlags"
+      export LDFLAGS="$linkFlags"
+
+      mkdir -p "$repoDir"
+
+      # session.json is ignored upstream, but protect the live credentials from a
+      # forced checkout in case upstream ever starts tracking a file with that
+      # name. If it does not exist, no session file is created here.
+      sessionFile="$repoDir/session.json"
+      sessionBackup=
+      if [ -f "$sessionFile" ]; then
+        sessionBackup=$(mktemp "$repoDir/.session.json.backup.XXXXXX")
+        cp -p "$sessionFile" "$sessionBackup"
+      fi
+
+      restoreSession() {
+        if [ -n "$sessionBackup" ] && [ -f "$sessionBackup" ]; then
+          mv -f "$sessionBackup" "$sessionFile"
+        fi
+      }
+      trap restoreSession EXIT
+
+      if [ ! -d "$repoDir/.git" ]; then
+        git -C "$repoDir" init -q
+        git -C "$repoDir" remote add origin "$repository"
+      else
+        git -C "$repoDir" remote set-url origin "$repository"
+      fi
+
+      if git ls-remote --exit-code --heads "$repository" "refs/heads/$branch" >/dev/null 2>&1; then
+        selectedBranch="$branch"
+      else
+        echo "Branch '$branch' no longer exists upstream; falling back to '$fallbackBranch'." >&2
+        selectedBranch="$fallbackBranch"
+      fi
+
+      git -C "$repoDir" fetch --prune --force origin "+refs/heads/*:refs/remotes/origin/*"
+      git -C "$repoDir" checkout -f -B "$selectedBranch" "origin/$selectedBranch"
+      git -C "$repoDir" reset --hard "origin/$selectedBranch"
+
+      restoreSession
+      trap - EXIT
+
+      # config.json is generated from this NixOS configuration. session.json is
+      # intentionally left alone: it is created once (pnpm new-session) and is
+      # refreshed/rewritten by the application itself.
+      install -m 0640 ${mobilizonCalendarMirrorConfigFile} "$repoDir/config.json"
+
+      cd "$repoDir"
+      pnpm install --frozen-lockfile
+    '';
+  };
+in
 {
   # System basics
   imports = [
     pkgs.vimPlugins.vim-suda
     pkgs.tmux
     pkgs.vimPlugins.comment-nvim
+    nodejs
+    pnpm
+    pkgs.git
   ];
 
   # Time zone and locale
   };
 
 
+  # Mobilizon calendar mirror (grical-to-mobilizon).
+  #
+  # The application is checked out at runtime so the lauti-to-mobilizon branch
+  # can be followed and automatically falls back to master once that branch is
+  # deleted. session.json is expected to exist in the checkout; it is never
+  # created or overwritten here, because the application refreshes and rewrites
+  # it after every successful login.
+  users.groups.calendarbot = { };
+
+  users.users.calendarbot = {
+    isSystemUser = true;
+    group = mobilizonCalendarMirror.group;
+    home = mobilizonCalendarMirror.home;
+    homeMode = "0750";
+    createHome = true;
+    shell = "${pkgs.shadow}/bin/nologin";
+  };
+
+  systemd.services.mobilizon-calendar-mirror-update = {
+    description = "Clone/update grical-to-mobilizon and install its pnpm dependencies";
+    wants = [ "network-online.target" ];
+    after = [ "network-online.target" ];
+    serviceConfig = {
+      Type = "oneshot";
+      User = mobilizonCalendarMirror.user;
+      Group = mobilizonCalendarMirror.group;
+      WorkingDirectory = mobilizonCalendarMirror.home;
+      ExecStart = "${mobilizonCalendarMirrorUpdate}/bin/mobilizon-calendar-mirror-update";
+
+      # Fetching and building native node modules needs more time than a run.
+      TimeoutStartSec = "20min";
+      NoNewPrivileges = true;
+      ProtectSystem = "strict";
+      ProtectHome = false;
+      ReadWritePaths = [ mobilizonCalendarMirror.home ];
+      PrivateTmp = true;
+      ProtectKernelTunables = true;
+      ProtectKernelModules = true;
+      ProtectControlGroups = true;
+      ProtectClock = true;
+      ProtectHostname = true;
+      RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
+      RestrictRealtime = true;
+      RestrictSUIDSGID = true;
+      LockPersonality = true;
+      SystemCallFilter = [ "@system-service" ];
+      SystemCallArchitectures = "native";
+    };
+  };
+
+  systemd.services.mobilizon-calendar-mirror = {
+    description = "Mobilizon calendar mirror (grical-to-mobilizon)";
+    wants = [ "network-online.target" ];
+    requires = [ "mobilizon-calendar-mirror-update.service" ];
+    after = [ "network-online.target" "mobilizon-calendar-mirror-update.service" ];
+    path = [ nodejs pnpm pkgs.coreutils ];
+    environment = {
+      HOME = mobilizonCalendarMirror.home;
+      SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
+      NODE_EXTRA_CA_CERTS = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
+    };
+    serviceConfig = {
+      Type = "oneshot";
+      User = mobilizonCalendarMirror.user;
+      Group = mobilizonCalendarMirror.group;
+      WorkingDirectory = mobilizonCalendarMirror.repoDir;
+
+      # Best-effort report first, then the actual sync. A failing report must
+      # not prevent the sync from running.
+      ExecStart = [
+        "-${pnpm}/bin/pnpm report --autoremove-deleted"
+        "${pnpm}/bin/pnpm start"
+      ];
+
+      # Type=oneshot: TimeoutStartSec doubles as the runtime limit.
+      TimeoutStartSec = "10min";
+      MemoryMax = "512M";
+      MemoryHigh = "384M";
+      TasksMax = 256;
+      UMask = "0077";
+
+      NoNewPrivileges = true;
+      CapabilityBoundingSet = "";
+      AmbientCapabilities = "";
+      ProtectSystem = "strict";
+      ProtectHome = false;
+      ReadWritePaths = [ mobilizonCalendarMirror.repoDir mobilizonCalendarMirror.home ];
+      PrivateTmp = true;
+      PrivateDevices = true;
+      ProtectKernelTunables = true;
+      ProtectKernelModules = true;
+      ProtectKernelLogs = true;
+      ProtectControlGroups = true;
+      ProtectClock = true;
+      ProtectHostname = true;
+      ProtectProc = "invisible";
+      ProcSubset = "pid";
+      RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
+      RestrictNamespaces = true;
+      RestrictRealtime = true;
+      RestrictSUIDSGID = true;
+      LockPersonality = true;
+      RemoveIPC = true;
+      SystemCallFilter = [ "@system-service" ];
+      SystemCallArchitectures = "native";
+    };
+  };
+
+  systemd.timers.mobilizon-calendar-mirror = {
+    description = "Run the Mobilizon calendar mirror every 8 hours";
+    wantedBy = [ "timers.target" ];
+    timerConfig = {
+      OnCalendar = "*-*-* 00/8:00:00";
+      Persistent = true;
+      RandomizedDelaySec = "15min";
+      Unit = "mobilizon-calendar-mirror.service";
+    };
+  };
+
   # SSH access
   services.openssh = {
     enable = true;