{ config, lib, pkgs, ... }: { options.services.panoptikon = { enable = lib.mkEnableOption "Generic command output / website watcher"; watchers = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule (watcher: { options = { script = lib.mkOption { type = lib.types.path; description = '' A script whose stdout is to be watched. ''; example = '' pkgs.writers.writeDash "github-meta" ''' ''${pkgs.curl}/bin/curl -sSL https://api.github.com/meta | ''${pkgs.jq}/bin/jq ''' ''; }; frequency = lib.mkOption { type = lib.types.str; description = '' How often to run the script. See systemd.time(7) for more information about the format. ''; example = "*:0/3"; default = "daily"; }; loadCredential = lib.mkOption { type = lib.types.listOf lib.types.str; description = '' This can be used to pass secrets to the systemd service without adding them to the nix store. ''; default = [ ]; }; reporters = lib.mkOption { type = lib.types.listOf lib.types.path; description = '' A list of scripts that take the diff (if any) via stdin and report it (e.g. to IRC, Telegram or Prometheus). The name of the watcher will be in the $PANOPTIKON_WATCHER environment variable. ''; example = '' [ (pkgs.writers.writeDash "telegram-reporter" ''' ''${pkgs.curl}/bin/curl -X POST https://api.telegram.org/bot''${TOKEN}/sendMessage \ -d chat_id=123456 \ -d text="$(cat)" ''') (pkgs.writers.writeDash "notify" ''' ''${pkgs.libnotify}/bin/notify-send "$PANOPTIKON_WATCHER has changed." ''') ] ''; }; }; config = { }; }) ); }; }; config = let cfg = config.services.panoptikon; stateDir = "/var/lib/panoptikon"; in lib.mkIf cfg.enable { users.extraUsers.panoptikon = { isSystemUser = true; createHome = true; home = stateDir; group = "panoptikon"; }; users.extraGroups.panoptikon = { }; systemd.timers = lib.attrsets.mapAttrs' ( watcherName: _: lib.nameValuePair "panoptikon-${watcherName}" { timerConfig.RandomizedDelaySec = toString (60 * 60); } ) cfg.watchers; systemd.services = lib.attrsets.mapAttrs' ( watcherName: watcherOptions: let # Absolute paths for the state files currentFile = "${stateDir}/${watcherName}"; oldFile = "${stateDir}/${watcherName}.old"; in lib.nameValuePair "panoptikon-${watcherName}" { enable = true; startAt = watcherOptions.frequency; serviceConfig = { Type = "oneshot"; User = "panoptikon"; Group = "panoptikon"; # ISOLATION: Start in a fresh, empty directory for every run RuntimeDirectory = "panoptikon/${watcherName}"; WorkingDirectory = "/run/panoptikon/${watcherName}"; # PERSISTENCE: Only allow write access to the specific state directory ReadWritePaths = [ stateDir ]; # HARDENING: Prevent the process from seeing most of the system ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; NoNewPrivileges = true; Restart = "on-failure"; LoadCredential = watcherOptions.loadCredential; }; environment.PANOPTIKON_WATCHER = watcherName; script = '' set -efu # Removed -x to keep tokens out of logs if they leak via env # 1. Run watcher and save to the persistent state dir ${watcherOptions.script} > "${currentFile}" # 2. Ensure .old exists so diff doesn't crash on first run [ -f "${oldFile}" ] || touch "${oldFile}" # 3. Compare diff_output=$(${pkgs.diffutils}/bin/diff --new-file "${oldFile}" "${currentFile}" || :) if [ -n "$diff_output" ]; then # 4. Run reporters. # They are running inside /run/panoptikon/${watcherName} (empty). # They can't see or delete other .old files via relative paths. ${lib.strings.concatMapStringsSep "\n" ( reporter: ''echo "$diff_output" | ${reporter} || :'' ) watcherOptions.reporters} fi # 5. Rotate state mv "${currentFile}" "${oldFile}" ''; } ) cfg.watchers; }; }