2026-02-20 17:22:14 +01:00
|
|
|
{
|
|
|
|
|
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;
|
2026-02-20 18:14:49 +01:00
|
|
|
stateDir = "/var/lib/panoptikon";
|
2026-02-20 17:22:14 +01:00
|
|
|
in
|
|
|
|
|
lib.mkIf cfg.enable {
|
|
|
|
|
users.extraUsers.panoptikon = {
|
|
|
|
|
isSystemUser = true;
|
|
|
|
|
createHome = true;
|
2026-02-20 18:14:49 +01:00
|
|
|
home = stateDir;
|
2026-02-20 17:22:14 +01:00
|
|
|
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:
|
2026-02-20 18:14:49 +01:00
|
|
|
let
|
|
|
|
|
# Absolute paths for the state files
|
|
|
|
|
currentFile = "${stateDir}/${watcherName}";
|
|
|
|
|
oldFile = "${stateDir}/${watcherName}.old";
|
|
|
|
|
in
|
2026-02-20 17:22:14 +01:00
|
|
|
lib.nameValuePair "panoptikon-${watcherName}" {
|
|
|
|
|
enable = true;
|
|
|
|
|
startAt = watcherOptions.frequency;
|
2026-02-20 18:14:49 +01:00
|
|
|
|
2026-02-20 17:22:14 +01:00
|
|
|
serviceConfig = {
|
|
|
|
|
Type = "oneshot";
|
|
|
|
|
User = "panoptikon";
|
|
|
|
|
Group = "panoptikon";
|
2026-02-20 18:14:49 +01:00
|
|
|
|
|
|
|
|
# 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;
|
|
|
|
|
|
2026-02-20 17:22:14 +01:00
|
|
|
Restart = "on-failure";
|
|
|
|
|
LoadCredential = watcherOptions.loadCredential;
|
|
|
|
|
};
|
2026-02-20 18:14:49 +01:00
|
|
|
|
2026-02-20 17:22:14 +01:00
|
|
|
environment.PANOPTIKON_WATCHER = watcherName;
|
2026-02-20 18:14:49 +01:00
|
|
|
|
2026-02-20 17:22:14 +01:00
|
|
|
script = ''
|
2026-02-20 18:14:49 +01:00
|
|
|
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.
|
2026-02-20 17:22:14 +01:00
|
|
|
${lib.strings.concatMapStringsSep "\n" (
|
|
|
|
|
reporter: ''echo "$diff_output" | ${reporter} || :''
|
|
|
|
|
) watcherOptions.reporters}
|
|
|
|
|
fi
|
2026-02-20 18:14:49 +01:00
|
|
|
|
|
|
|
|
# 5. Rotate state
|
|
|
|
|
mv "${currentFile}" "${oldFile}"
|
2026-02-20 17:22:14 +01:00
|
|
|
'';
|
|
|
|
|
}
|
|
|
|
|
) cfg.watchers;
|
|
|
|
|
};
|
|
|
|
|
}
|