{ pkgs, config, flake-inputs, ... }: let domain = "ntfy.${config.services.nginx.domain}"; in { imports = [ ./downstream-module.nix ]; networking.firewall.allowedTCPPorts = [ 80 443 ]; services.ntfy-sh = { enable = true; environmentFile = config.sops.secrets."ntfy/users".path; settings = { base-url = "https://${domain}"; listen-http = "127.0.0.1:2586"; behind-proxy = true; # Paths attachment-cache-dir = "/var/lib/ntfy-sh/attachments"; cache-file = "/var/lib/ntfy-sh/cache-file.db"; auth-file = "/var/lib/ntfy-sh/user.db"; auth-default-access = "deny-all"; auth-access = [ "*:local-*:wo" ]; # Don't want to host the front-end web-root = "disable"; }; }; services.nginx.virtualHosts."ntfy.${config.services.nginx.domain}" = { forceSSL = true; useACMEHost = "tlater.net"; enableHSTS = true; locations."/" = { proxyWebsockets = true; proxyPass = "http://${config.services.ntfy-sh.settings.listen-http}"; extraConfig = '' client_max_body_size 0; # Stream request body to backend ''; }; # Don't allow writing to topics with the local prefix, *including* # webhook writes, since they are set to write-only access from # anyone. locations."/local" = { proxyWebsockets = true; proxyPass = "http://${config.services.ntfy-sh.settings.listen-http}"; extraConfig = '' client_max_body_size 0; # Stream request body to backend limit_except GET OPTIONS { deny all; } location ~ /trigger$ { deny all; } ''; }; }; sops.secrets."ntfy/users" = { }; serviceTests = { testNtfyConfig = pkgs.testers.runNixOSTest { name = "test-ntfy-config"; node.specialArgs = { inherit flake-inputs; }; nodes = { testHost = { lib, ... }: { imports = [ ./. ../../nginx ../../../modules/serviceTests/mocks.nix ]; services.nginx.domain = "testHost"; # Don't care for testing SSL here services.nginx.virtualHosts."ntfy.testHost" = { forceSSL = lib.mkForce false; enableHSTS = lib.mkForce false; }; }; client = { pkgs, ... }: { environment.systemPackages = [ pkgs.curl ]; networking.hosts."192.168.1.2" = [ "ntfy.testHost" ]; }; }; testScript = '' import json import time from contextlib import contextmanager def read_client_messages(): client.wait_for_unit("messages.service") messages = [json.loads(line) for line in client.succeed("cat messages").split()] client.succeed("systemctl stop messages.service") client.succeed("rm messages") print(messages) return messages @contextmanager def client_subscribe(topic: str, timeout: int = 2): systemd_invocation = [ "systemd-run", "--unit messages", "--property=Type=oneshot", "--property=SuccessExitStatus=28", "--remain-after-exit", "--setenv=PATH=$PATH", "--same-dir", "--no-block", "/bin/sh -c" ] curl = [ "curl", "--silent", "--show-error", f"--max-time {timeout}", "-u tlater:insecure", f"http://ntfy.testHost/{topic}/json", "> messages" ] client.succeed(f'{" ".join(systemd_invocation)} "{" ".join(curl)}"') # Give some slack so the host doesn't send messages before # we're listening time.sleep(1) yield start_all() testHost.wait_for_unit("ntfy-sh.service") client.wait_until_succeeds("curl http://ntfy.testHost") with subtest("subscribing and writing to local topics works"): with client_subscribe("local-test"): testHost.succeed("curl --fail --silent --show-error --data test http://127.0.0.1:2586/local-test") messages = read_client_messages() t.assertEqual(len(messages), 2) t.assertEqual(messages[1].get("message"), "test") with subtest("writing to non-local topics without auth fails"): testHost.fail("curl --fail --silent --show-error --data test http://127.0.0.1:2586/test") with subtest("writing to *any* topics from outside localhost fails"): client.fail("curl --fail --silent --show-error --data test http://ntfy.testHost/test") client.fail("curl --fail --silent --show-error --data test http://ntfy.testHost/local-test") # GET requests work by default because websocket shenanigans client.fail("curl --fail --silent --show-error http://ntfy.testHost/local-test/trigger?message=test") with subtest("authenticated messaging works from outside localhost"): with client_subscribe("test", 10): client.succeed("curl -u tlater:insecure --fail --silent --show-error --data test http://ntfy.testHost/test") client.succeed("curl -u tlater:insecure --fail --silent --show-error http://ntfy.testHost/test/trigger?message=test2") messages = read_client_messages() t.assertEqual(len(messages), 3) t.assertEqual(messages[1].get("message"), "test") t.assertEqual(messages[2].get("message"), "test2") ''; }; }; }