diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index 6896572..aa752a5 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -39,6 +39,7 @@ "extranix" "openbao" "forgejo-multiuser-nix-runners" + "ntfy-sh" ]) ++ [ "${sources.agenix}/modules/age.nix" @@ -55,7 +56,6 @@ "services/systemd-notify" "services/victorialogs" "services/victoriametrics" - "services/ntfy-sh" ] ++ nodeMeta.nix-modules )); diff --git a/modules/nixos/ntfy-sh/default.nix b/modules/nixos/ntfy-sh/default.nix new file mode 100644 index 0000000..a11cb73 --- /dev/null +++ b/modules/nixos/ntfy-sh/default.nix @@ -0,0 +1,148 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + mapAttrsToList + mkEnableOption + mkIf + mkOption + xor + ; + inherit (lib.types) + attrsOf + enum + listOf + nullOr + path + str + submodule + ; + + cfg = config.services.ntfy-sh.accessControl; + + inherit (config.services.ntfy-sh) settings; + + acl_file = (pkgs.formats.json { }).generate "acl.json" { inherit (cfg) access users; }; + + ntfy-acl = pkgs.substituteAll { + inherit (pkgs) python3; + inherit acl_file; + + user_db = settings.auth-file; + + name = "ntfy-acl"; + src = ./ntfy-acl.py; + dir = "bin"; + isExecutable = true; + }; +in + +{ + options.services.ntfy-sh.accessControl = { + enable = mkEnableOption "declarative management of users and acl for ntfy.sh"; + + users = mkOption { + type = attrsOf (submodule { + options = { + role = mkOption { + type = enum [ + "admin" + "user" + ]; + description = "Role of the user."; + default = "user"; + }; + passwordFile = mkOption { + type = nullOr path; + description = '' + Path to a file containing the password of the user. + + Conflicts with `hashedPassword`. + ''; + default = null; + }; + hashedPassword = mkOption { + type = nullOr str; + description = '' + Hashed password of the user. + + Conflicts with `passwordFile`. + ''; + default = null; + }; + }; + }); + description = '' + Attribute set defining users of the ntfy.sh instance. + ''; + default = { }; + }; + + access = mkOption { + type = listOf (submodule { + options = { + username = mkOption { + type = str; + description = '' + A USERNAME is an existing user, as created with ntfy user add (see users and roles), + or the anonymous user everyone or *, which represents clients that access the API + without username/password. + ''; + default = "*"; + }; + topic = mkOption { + type = str; + description = '' + A TOPIC is either a specific topic name (e.g. mytopic, or phil_alerts), + or a wildcard pattern that matches any number of topics (e.g. alerts_* or ben-*). + Only the wildcard character * is supported. + It stands for zero to any number of characters. + ''; + }; + permission = mkOption { + type = enum [ + "rw" + "ro" + "wo" + "none" + ]; + description = '' + Permission for this access. + - rw: Allows publishing messages to the given topic, as well as subscribing and reading messages + - ro: Allows only subscribing and reading messages, but not publishing to the topic + - wo: Allows only publishing to the topic, but not subscribing to it + - none: Allows neither publishing nor subscribing to a topic + ''; + }; + }; + }); + description = "List of access control rules."; + default = [ ]; + }; + }; + + config = mkIf cfg.enable { + assertions = mapAttrsToList (name: user: { + assertion = xor (user.hashedPassword != null) (user.passwordFile != null); + message = '' + Exactly one of `services.ntfy-sh.accessControl.users..hashedPassword` + and `services.ntfy-sh.accessControl.users..passwordFile` + is required for `name` = `${name}`. + ''; + }) cfg.users; + + services.ntfy-sh.settings = { + enable-signup = false; + }; + + systemd.tmpfiles.rules = [ + "f /var/lib/ntfy-sh/.acl-path 0600 ${config.services.ntfy-sh.user} ${config.services.ntfy-sh.group} - -" + ]; + systemd.services.ntfy-sh.preStart = "${ntfy-acl}/bin/ntfy-acl"; + }; +} diff --git a/modules/nixos/ntfy-sh/ntfy-acl.py b/modules/nixos/ntfy-sh/ntfy-acl.py new file mode 100644 index 0000000..3bcb33a --- /dev/null +++ b/modules/nixos/ntfy-sh/ntfy-acl.py @@ -0,0 +1,86 @@ +#!@python3@/bin/python + +import json +import sqlite3 +import subprocess + + +def ntfy(*args: str, env=None): + subprocess.run(["ntfy"] + list(args), env=env).check_returncode() + + +def create_user(u: str, role: str, passwordFile: str, hashedPassword: str): + # Create the user with the required role and password + if passwordFile != None: + with open(passwordFile) as pwd_fp: + env = {"NTFY_PASSWORD": pwd_fp.read().strip()} + + ntfy("user", "add", f"--role={role}", u, env=env) + else: + env = {"NTFY_PASSWORD": hashedPassword} + + ntfy("user", "add", f"--role={role}", u, env=env) + # HACK: add does not supports hashedPassword entry + ntfy("user", "change-pass-hash", u, env=env) + +def update_user(u: str, role: str, passwordFile: str, hashedPassword: str): + # Update the user with the required role and password + if passwordFile != None: + with open(passwordFile) as pwd_fp: + env = {"NTFY_PASSWORD": pwd_fp.read().strip()} + + ntfy("user", "change-pass", u, env=env) + else: + env = {"NTFY_PASSWORD": hashedPassword} + + ntfy("user", "change-pass-hash", u, env=env) + + ntfy("user", "change-role", u, role) + + +# Compare the ACL file path to the one used to get the actual data +try: + with open("/var/lib/ntfy-sh/.acl-path") as acl_path_fp: + acl_path: str = acl_path_fp.read().strip() +except OSError: + print("[!] Cannot open .acl-path") + exit(1) + +if acl_path == "@acl_file@": + print("[-] Unchanged ACL file, exiting") + exit(0) +else: + print("[+] ACL file has changed, updating data") + +# Get the wanted state +with open("@acl_file@") as acl_fp: + acl_data = json.load(acl_fp) + +# Connect to the db to recover the list of current users +with sqlite3.connect("@user_db@") as con: + c = con.cursor() + existing_users: set[str] = set(c.execute("SELECT user FROM user")) - {"*"} + +wanted_users: set[str] = set(acl_data["users"].keys()) + +# Delete extraneous users +for user in existing_users - wanted_users: + ntfy("user", "del", user) + +# Create new users +for user in wanted_users - existing_users: + create_user(user, **acl_data["users"][user]) + +# Update existing users +for user in existing_users & wanted_users: + update_user(user, **acl_data["users"][user]) + +# Reset ACL rules +ntfy("access", "--reset") + +for rule in acl_data["access"]: + ntfy("access", rule["user"], rule["topic"], rule["permission"]) + +# Write the new ACL file path +with open("/var/lib/ntfy-sh/.acl-path", "w") as f: + f.write("@acl_file@")