Unverified Commit 1626880b authored by Adam C. Stephens's avatar Adam C. Stephens Committed by GitHub
Browse files

grafana-to-ntfy: 0-unstable-2025-01-25 -> 2026.4.29, fix module bugs, add NixOS test (#503343)

parents 65ccec4e eec2e429
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -14271,6 +14271,13 @@
    githubId = 10689811;
    name = "Torben Schweren";
  };
  kittyandrew = {
    email = "alias.nixpkgs.maintainer@kittymail.me";
    github = "kittyandrew";
    githubId = 45767571;
    matrix = "@kittyandrew:ndrew.me";
    name = "kittyandrew";
  };
  kittywitch = {
    email = "kat@inskip.me";
    github = "kittywitch";
+91 −23
Original line number Diff line number Diff line
@@ -9,9 +9,11 @@ let
  cfg = config.services.grafana-to-ntfy;
in
{
  meta.maintainers = with lib.maintainers; [ kittyandrew ];

  options = {
    services.grafana-to-ntfy = {
      enable = lib.mkEnableOption "Grafana-to-ntfy (ntfy.sh) alerts channel";
      enable = lib.mkEnableOption "grafana-to-ntfy, a Grafana/Alertmanager to ntfy.sh bridge";

      package = lib.mkPackageOption pkgs "grafana-to-ntfy" { };

@@ -33,64 +35,127 @@ in
        };

        ntfyBAuthPass = lib.mkOption {
          type = lib.types.path;
          type = lib.types.nullOr lib.types.path;
          description = ''
            The path to the password for the specified ntfy-sh user.
            Setting this option is required when using a ntfy-sh instance with access control enabled.
          '';
          default = null;
          example = "/run/secrets/grafana-to-ntfy-ntfy-pass";
        };

        bauthUser = lib.mkOption {
          type = lib.types.str;
          type = lib.types.nullOr lib.types.str;
          description = ''
            The user that you will authenticate with in the Grafana webhook settings.
            You can set this to whatever you like, as this is not the same as the ntfy-sh user.
            The user for Basic Auth on incoming webhook requests from Grafana or Alertmanager.
            When set together with {option}`bauthPass`, incoming requests require Basic Auth.
            When both are null, the endpoint is open (unauthenticated).
          '';
          default = "admin";
          default = null;
          example = "admin";
        };

        bauthPass = lib.mkOption {
          type = lib.types.path;
          description = "The path to the password you will use in the Grafana webhook settings.";
          type = lib.types.nullOr lib.types.path;
          description = ''
            Path to the password file for Basic Auth on incoming webhook requests.
            When set together with {option}`bauthUser`, incoming requests require Basic Auth.
            When both are null, the endpoint is open (unauthenticated).
          '';
          default = null;
          example = "/run/secrets/grafana-to-ntfy-bauth-pass";
        };

        markdown = lib.mkOption {
          type = lib.types.bool;
          description = "Enable Markdown formatting in ntfy notifications. Sets the X-Markdown header.";
          default = false;
        };

        port = lib.mkOption {
          type = lib.types.port;
          description = "Port to listen on.";
          default = 8080;
        };

        address = lib.mkOption {
          type = lib.types.str;
          description = "Address to listen on.";
          default = "127.0.0.1";
          example = "0.0.0.0";
        };
      };
    };
  };

  config = lib.mkIf cfg.enable {
    assertions = [
      {
        assertion = (cfg.settings.bauthUser == null) == (cfg.settings.bauthPass == null);
        message = "services.grafana-to-ntfy: bauthUser and bauthPass must both be set or both be null";
      }
      {
        assertion = (cfg.settings.ntfyBAuthUser == null) == (cfg.settings.ntfyBAuthPass == null);
        message = "services.grafana-to-ntfy: ntfyBAuthUser and ntfyBAuthPass must both be set or both be null";
      }
    ];

    systemd.services.grafana-to-ntfy = {
      description = "Grafana/Alertmanager to ntfy.sh bridge";
      wantedBy = [ "multi-user.target" ];
      wants = [ "network-online.target" ];
      after = [ "network-online.target" ];

      script = ''
        export BAUTH_PASS=$(${lib.getExe' config.systemd.package "systemd-creds"} cat BAUTH_PASS_FILE)
        ${lib.optionalString (cfg.settings.ntfyBAuthPass != null) ''
          export NTFY_BAUTH_PASS=$(${lib.getExe' config.systemd.package "systemd-creds"} cat NTFY_BAUTH_PASS_FILE)
        ''}
      script =
        let
          optionalCred = name: envVar: ''
            export ${envVar}="$(${lib.getExe' config.systemd.package "systemd-creds"} cat ${name})"
          '';
        in
        ''
          ${lib.optionalString (cfg.settings.bauthPass != null) (optionalCred "BAUTH_PASS_FILE" "BAUTH_PASS")}
          ${lib.optionalString (cfg.settings.ntfyBAuthPass != null) (
            optionalCred "NTFY_BAUTH_PASS_FILE" "NTFY_BAUTH_PASS"
          )}
          exec ${lib.getExe cfg.package}
        '';

      environment = {
        NTFY_URL = cfg.settings.ntfyUrl;
        ROCKET_PORT = toString cfg.settings.port;
        ROCKET_ADDRESS = cfg.settings.address;
      }
      // lib.optionalAttrs (cfg.settings.bauthUser != null) {
        BAUTH_USER = cfg.settings.bauthUser;
      }
      // lib.optionalAttrs (cfg.settings.ntfyBAuthUser != null) {
        NTFY_BAUTH_USER = cfg.settings.ntfyBAuthUser;
      }
      // lib.optionalAttrs cfg.settings.markdown {
        MARKDOWN = "true";
      };

      serviceConfig = {
        LoadCredential = [
          "BAUTH_PASS_FILE:${cfg.settings.bauthPass}"
        ]
        LoadCredential =
          lib.optional (cfg.settings.bauthPass != null) "BAUTH_PASS_FILE:${cfg.settings.bauthPass}"
          ++ lib.optional (
            cfg.settings.ntfyBAuthPass != null
          ) "NTFY_BAUTH_PASS_FILE:${cfg.settings.ntfyBAuthPass}";

        DynamicUser = true;

        Restart = "always";
        RestartSec = 5;

        # Hardening
        AmbientCapabilities = [ "" ];
        CapabilityBoundingSet = [ "" ];
        DeviceAllow = "";
        DevicePolicy = "closed";
        LockPersonality = true;
        MemoryDenyWriteExecute = true;
        NoNewPrivileges = true;
        PrivateDevices = true;
        PrivateTmp = true;
        PrivateUsers = true;
        ProcSubset = "pid";
        ProtectClock = true;
@@ -101,6 +166,8 @@ in
        ProtectKernelModules = true;
        ProtectKernelTunables = true;
        ProtectProc = "invisible";
        ProtectSystem = "strict";
        RemoveIPC = true;
        RestrictAddressFamilies = [
          "AF_INET"
          "AF_INET6"
@@ -108,11 +175,12 @@ in
        ];
        RestrictNamespaces = true;
        RestrictRealtime = true;
        MemoryDenyWriteExecute = true;
        RestrictSUIDSGID = true;
        SystemCallArchitectures = "native";
        SystemCallFilter = [
          "@system-service"
          "~@privileged"
          "~@resources"
        ];
        UMask = "0077";
      };
+1 −0
Original line number Diff line number Diff line
@@ -678,6 +678,7 @@ in
  gotosocial = runTest ./web-apps/gotosocial.nix;
  goupile = runTest ./web-apps/goupile;
  grafana = handleTest ./grafana { };
  grafana-to-ntfy = runTest ./grafana-to-ntfy.nix;
  graphite = runTest ./graphite.nix;
  grav = runTest ./web-apps/grav.nix;
  graylog = runTest ./graylog.nix;
+166 −0
Original line number Diff line number Diff line
{ lib, ... }:

let
  ports = {
    grafana-to-ntfy = 8080;
    ntfy-sh = 8081;
    grafana = 3000;
    alertmanager = 9093;
  };
  ntfyTopic = "grafana-alerts";
in

{
  name = "grafana-to-ntfy";
  meta.maintainers = with lib.maintainers; [ kittyandrew ];

  nodes.machine = {
    services.grafana-to-ntfy = {
      enable = true;
      settings = {
        ntfyUrl = "http://127.0.0.1:${toString ports.ntfy-sh}/${ntfyTopic}";
        port = ports.grafana-to-ntfy;
        address = "127.0.0.1";
      };
    };

    services.ntfy-sh = {
      enable = true;
      settings = {
        listen-http = "127.0.0.1:${toString ports.ntfy-sh}";
        base-url = "http://127.0.0.1:${toString ports.ntfy-sh}";
      };
    };

    services.grafana = {
      enable = true;
      settings = {
        server.http_port = ports.grafana;
        server.http_addr = "127.0.0.1";
        security.admin_user = "admin";
        security.admin_password = "admin";
        security.secret_key = "test-only-dummy-key";
      };
      provision.alerting = {
        contactPoints.settings = {
          apiVersion = 1;
          contactPoints = [
            {
              orgId = 1;
              name = "grafana-to-ntfy";
              receivers = [
                {
                  uid = "cp_webhook";
                  type = "webhook";
                  disableResolveMessage = false;
                  settings = {
                    url = "http://127.0.0.1:${toString ports.grafana-to-ntfy}";
                    httpMethod = "POST";
                  };
                }
              ];
            }
          ];
        };
        policies.settings = {
          apiVersion = 1;
          policies = [
            {
              orgId = 1;
              receiver = "grafana-to-ntfy";
              group_by = [ "..." ];
              group_wait = "0s";
              group_interval = "1s";
              repeat_interval = "1h";
            }
          ];
        };
      };
    };

    services.prometheus.alertmanager = {
      enable = true;
      listenAddress = "127.0.0.1";
      port = ports.alertmanager;
      configuration = {
        route = {
          receiver = "grafana-to-ntfy";
          group_by = [ "..." ];
          group_wait = "0s";
          group_interval = "1s";
          repeat_interval = "2h";
        };
        receivers = [
          {
            name = "grafana-to-ntfy";
            webhook_configs = [
              { url = "http://127.0.0.1:${toString ports.grafana-to-ntfy}"; }
            ];
          }
        ];
      };
    };
  };

  interactive.nodes.machine = {
    services.grafana-to-ntfy.settings.address = lib.mkForce "0.0.0.0";
    services.grafana.settings.server.http_addr = lib.mkForce "0.0.0.0";
    services.prometheus.alertmanager.listenAddress = lib.mkForce "0.0.0.0";
    services.ntfy-sh.settings.listen-http = lib.mkForce "0.0.0.0:${toString ports.ntfy-sh}";
    networking.firewall.enable = false;
    virtualisation.forwardPorts = lib.mapAttrsToList (_: port: {
      from = "host";
      host = { inherit port; };
      guest = { inherit port; };
    }) ports;
  };

  testScript = ''
    import json

    machine.wait_for_unit("grafana-to-ntfy.service")
    machine.wait_for_unit("ntfy-sh.service")
    machine.wait_for_unit("grafana.service")
    machine.wait_for_unit("alertmanager.service")
    machine.wait_for_open_port(${toString ports.grafana-to-ntfy})
    machine.wait_for_open_port(${toString ports.ntfy-sh})
    machine.wait_for_open_port(${toString ports.grafana})
    machine.wait_for_open_port(${toString ports.alertmanager})

    with subtest("Health endpoint returns 200"):
      machine.succeed("curl -sf http://127.0.0.1:${toString ports.grafana-to-ntfy}/health")

    with subtest("Alertmanager alert arrives at ntfy"):
      machine.succeed(
        "curl -sf http://127.0.0.1:${toString ports.alertmanager}/api/v2/alerts"
        " -X POST -H 'Content-Type: application/json'"
        " -d '[{\"labels\": {\"alertname\": \"TestAlertFromAM\"}}]'"
      )
      # grep makes wait_until_succeeds retry: ntfy returns 200 with empty body when no messages exist
      resp = machine.wait_until_succeeds(
        "curl -sf 'http://127.0.0.1:${toString ports.ntfy-sh}/${ntfyTopic}/json?poll=1'"
        " | grep '\"title\":\"Alertmanager\"'"
      )
      msg = json.loads(resp.strip())
      assert msg["title"] == "Alertmanager", f"Expected title 'Alertmanager', got '{msg['title']}'"
      assert "warning" in msg["tags"], f"Expected 'warning' in tags, got {msg['tags']}"
      assert "firing" in msg["tags"], f"Expected 'firing' in tags, got {msg['tags']}"

    with subtest("Grafana alert arrives at ntfy"):
      machine.succeed(
        "curl -sf http://127.0.0.1:${toString ports.grafana}/api/alertmanager/grafana/config/api/v1/receivers/test"
        " -u admin:admin"
        " -X POST -H 'Content-Type: application/json'"
        """ -d '{"receivers": [{"name": "grafana-to-ntfy", "grafana_managed_receiver_configs": [{"uid": "cp_webhook", "name": "webhook", "type": "webhook", "disableResolveMessage": false, "settings": {"url": "http://127.0.0.1:${toString ports.grafana-to-ntfy}", "httpMethod": "POST"}}]}]}'"""
      )
      # grep ensures we wait for the Grafana message specifically (see above)
      resp = machine.wait_until_succeeds(
        "curl -sf 'http://127.0.0.1:${toString ports.ntfy-sh}/${ntfyTopic}/json?poll=1'"
        " | grep 'FIRING'"
      )
      msg = json.loads(resp.strip())
      assert "[FIRING:1]" in msg["title"], f"Expected Grafana title with '[FIRING:1]', got '{msg['title']}'"
      assert "warning" in msg["tags"], f"Expected 'warning' in tags, got {msg['tags']}"
      assert "firing" in msg["tags"], f"Expected 'firing' in tags, got {msg['tags']}"
  '';
}
+20 −9
Original line number Diff line number Diff line
{
  lib,
  rustPlatform,
  fetchFromGitHub,
  rustPlatform,
  nixosTests,
  nix-update-script,
}:

rustPlatform.buildRustPackage {
rustPlatform.buildRustPackage (finalAttrs: {
  pname = "grafana-to-ntfy";
  version = "0-unstable-2025-01-25";
  version = "2026.4.29";

  src = fetchFromGitHub {
    owner = "kittyandrew";
    repo = "grafana-to-ntfy";
    rev = "64d11f553776bbf7695d9febd74da1bad659352d";
    hash = "sha256-GO9VE9wymRk+QKGFyDpd0wS9GCY3pjpFUe37KIcnKxc=";
    tag = "v${finalAttrs.version}";
    hash = "sha256-ac0T8SNCDH9kQTKIfYn9KinnrSCYIBpNByO6NQ8UntA=";
  };

  cargoHash = "sha256-w4HSxdihElPz0q05vWjajQ9arZjAzd82L0kEKk1Uk8s=";
  cargoHash = "sha256-RuWXlofcruR69sg+RO2v1DBgxaPEyu8TeZEiZP7rBV8=";

  # No unit tests; all testing is NixOS VM-based integration tests
  doCheck = false;

  passthru = {
    tests.grafana-to-ntfy = nixosTests.grafana-to-ntfy;
    updateScript = nix-update-script { };
  };

  meta = {
    description = "Bridge to forward Grafana alerts to ntfy.sh notification service";
    description = "Bridge to forward Grafana and Prometheus Alertmanager alerts to ntfy.sh";
    homepage = "https://github.com/kittyandrew/grafana-to-ntfy";
    changelog = "https://github.com/kittyandrew/grafana-to-ntfy/releases/tag/v${finalAttrs.version}";
    license = lib.licenses.agpl3Only;
    platforms = lib.platforms.linux;
    maintainers = [ ];
    maintainers = with lib.maintainers; [ kittyandrew ];
    mainProgram = "grafana-to-ntfy";
  };
}
})