nixos/grafana-to-ntfy: fix type bugs, add missing options, add NixOS test
Fixes: - ntfyBAuthPass: type was `path` with `default = null` — eval crash (supersedes #399155, #423598) - bauthPass: was required with no default — blocked unauthenticated use - bauthUser: defaulted to "admin", silently forcing auth even when not configured - systemd-creds: quote command substitution to handle passwords with special characters - BAUTH_USER env var leaked when auth was not configured New options: markdown, port, address Reliability: network-online.target ordering, Restart=always, RestrictSUIDSGID, ProtectSystem=strict, and other hardening aligned with alertmanager-ntfy module. Adds NixOS VM test covering health endpoint, Alertmanager webhook path, and Grafana receiver test API path with field assertions on ntfy notifications.
This commit is contained in:
@@ -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,124 @@ 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 {
|
||||
systemd.services.grafana-to-ntfy = {
|
||||
wantedBy = [ "multi-user.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)
|
||||
''}
|
||||
exec ${lib.getExe cfg.package}
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NTFY_URL = cfg.settings.ntfyUrl;
|
||||
BAUTH_USER = cfg.settings.bauthUser;
|
||||
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";
|
||||
}
|
||||
// lib.optionalAttrs (cfg.settings.ntfyBAuthUser != null) {
|
||||
NTFY_BAUTH_USER = cfg.settings.ntfyBAuthUser;
|
||||
};
|
||||
{
|
||||
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 =
|
||||
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}"
|
||||
]
|
||||
++ lib.optional (
|
||||
cfg.settings.ntfyBAuthPass != null
|
||||
) "NTFY_BAUTH_PASS_FILE:${cfg.settings.ntfyBAuthPass}";
|
||||
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 +163,8 @@ in
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectProc = "invisible";
|
||||
ProtectSystem = "strict";
|
||||
RemoveIPC = true;
|
||||
RestrictAddressFamilies = [
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
@@ -108,11 +172,12 @@ in
|
||||
];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictSUIDSGID = true;
|
||||
SystemCallArchitectures = "native";
|
||||
SystemCallFilter = [
|
||||
"@system-service"
|
||||
"~@privileged"
|
||||
"~@resources"
|
||||
];
|
||||
UMask = "0077";
|
||||
};
|
||||
|
||||
@@ -674,6 +674,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;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
{ 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']}"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user