aboutsummaryrefslogtreecommitdiffstats
path: root/modules/server.nix
diff options
context:
space:
mode:
authorTim Keller <tjk@tjkeller.xyz>2026-08-27 23:22:24 -0500
committerTim Keller <tjk@tjkeller.xyz>2026-08-27 23:22:24 -0500
commitfe770e9f386e091dbca7cf587cfe0131f10bd728 (patch)
treea673fa706f1049c5fa43bb78fc3d970aebe28455 /modules/server.nix
parent89f2c328fc1b113a9e9b862841e0c7d0391b3d09 (diff)
downloadtelemetry-flake-fe770e9f386e091dbca7cf587cfe0131f10bd728.tar.xz
telemetry-flake-fe770e9f386e091dbca7cf587cfe0131f10bd728.zip
add server.alerts: Alertmanager with email notifications
Turns the Alertmanager wiring sketched out in conversation into an option, and ships the alerting rules rather than leaving them as something to copy out of a chat log. services.telemetry.server.alerts = { enable = true; to = [ "you@example.com" ]; from = "alerts@example.com"; smarthost = "smtp.fastmail.com:587"; username = "alerts@example.com"; passwordFile = config.sops.secrets.smtp-password.path; }; passwordFile becomes smtp_auth_password_file, read at runtime, so the password never enters the store. `settings` is merged over the generated configuration for inhibit rules and severity routing. Alerts group by alertname and host, so nine failing drives on one machine arrive as one mail. rules/alerts.yml carries twelve alerts: drive failing, degraded, failed self-test, overheating; pool not ONLINE, unrepairable data errors, over 85% full, scrub or snapshots overdue; exporter down, collector stale, textfile parse error. Most are one-liners because rules/smartctl.yml has already reduced drive health to a single smartctl:health_code. ExporterDown earns its place by covering the case where a machine vanishes and every other alert goes quiet. Alertmanager is defined inside the existing services.prometheus block: Nix will not let one attrset both define services.prometheus wholesale and add a path beneath it. Same shape as the grafana settings fix in 89f2c32. Verified by building both ways. Enabled: two promtool-checked rule files, an amtool-checked Alertmanager config with the password file, and Prometheus pointing at 127.0.0.1:9093. Disabled: no alertmanager unit, one rule file, no alerting stanza. The README's rule-override and alerts.settings snippets were also built rather than written from memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JKczN1f66kmLxNBLX37voQ
Diffstat (limited to 'modules/server.nix')
-rw-r--r--modules/server.nix146
1 files changed, 145 insertions, 1 deletions
diff --git a/modules/server.nix b/modules/server.nix
index d9449a8..c6094ef 100644
--- a/modules/server.nix
+++ b/modules/server.nix
@@ -250,6 +250,90 @@ in
};
};
+ alerts = {
+ enable = mkEnableOption ''
+ Alertmanager, wired to Prometheus and delivering by email.
+
+ The alerting rules themselves are plain PromQL in rules/alerts.yml, checked by
+ promtool at build time like everything else here -- so a typo in an expression
+ fails `nixos-rebuild`, not at 3am
+ '';
+
+ defaultRules = mkOption {
+ type = types.bool;
+ default = true;
+ description = ''
+ Load the alerting rules shipped with this module: drive failing or degraded,
+ failed self-test, drive overheating, pool not ONLINE, unrepairable data
+ errors, pool over 85% full, scrub or snapshots overdue, exporter down, and a
+ collector that has stopped refreshing its file.
+
+ Turn this off and point
+ {option}`services.telemetry.server.prometheus.ruleFiles` at your own copy to
+ change the thresholds.
+ '';
+ };
+
+ to = mkOption {
+ type = types.listOf types.str;
+ example = [ "you@example.com" ];
+ description = "Where notifications are sent.";
+ };
+
+ from = mkOption {
+ type = types.str;
+ example = "alerts@example.com";
+ description = "Envelope sender. Many providers require this to be the account you authenticate as.";
+ };
+
+ smarthost = mkOption {
+ type = types.str;
+ example = "smtp.fastmail.com:587";
+ description = "SMTP server and port to relay through.";
+ };
+
+ username = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "SMTP username. Null sends unauthenticated, which only works for a local relay.";
+ };
+
+ passwordFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ example = lib.literalExpression "config.sops.secrets.smtp-password.path";
+ description = ''
+ File containing the SMTP password. Passed to Alertmanager as
+ `smtp_auth_password_file` and read at runtime, so it never enters the Nix
+ store. Must be readable by the alertmanager user.
+ '';
+ };
+
+ requireTLS = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Require STARTTLS. Turn off only for a local relay on loopback.";
+ };
+
+ repeatInterval = mkOption {
+ type = types.str;
+ default = "24h";
+ description = ''
+ How long before an alert that is still firing is re-sent. A drive does not
+ stop failing, so this is the knob that decides how often it nags you.
+ '';
+ };
+
+ settings = mkOption {
+ type = types.attrs;
+ default = { };
+ description = ''
+ Merged over the generated Alertmanager configuration, last wins. The escape
+ hatch for inhibit rules, extra receivers, or routing by severity.
+ '';
+ };
+ };
+
proxy = {
enable = mkOption {
type = types.bool;
@@ -287,6 +371,14 @@ in
'';
}
{
+ assertion = !cfg.alerts.enable || cfg.prometheus.enable;
+ message = ''
+ services.telemetry.server.alerts.enable requires prometheus.enable -- the
+ alerting rules are evaluated by Prometheus, which then pushes to
+ Alertmanager.
+ '';
+ }
+ {
assertion = !cfg.grafana.enable || secretKeyPath != null;
message = ''
Grafana needs a secret key. Either leave
@@ -348,7 +440,8 @@ in
scrape_timeout = "10s";
};
- inherit (cfg.prometheus) ruleFiles;
+ ruleFiles = cfg.prometheus.ruleFiles
+ ++ lib.optional (cfg.alerts.enable && cfg.alerts.defaultRules) ../rules/alerts.yml;
scrapeConfigs =
optionals (cfg.prometheus.nodeTargets != [ ]) [{
@@ -364,6 +457,54 @@ in
static_configs = [{ targets = cfg.prometheus.smartctlTargets; }];
}]
++ cfg.prometheus.extraScrapeConfigs;
+
+ # Alertmanager lives inside this block rather than in a `services.prometheus.*`
+ # definition of its own: Nix will not let one attrset both define
+ # `services.prometheus` wholesale and add a path underneath it.
+ alertmanagers = mkIf cfg.alerts.enable [{
+ static_configs = [{ targets = [ "127.0.0.1:${toString ports.alertmanager}" ]; }];
+ }];
+
+ alertmanager = mkIf cfg.alerts.enable {
+ enable = true;
+ port = ports.alertmanager;
+ listenAddress = cfg.listenAddress;
+ configuration = lib.recursiveUpdate
+ {
+ global = {
+ smtp_smarthost = cfg.alerts.smarthost;
+ smtp_from = cfg.alerts.from;
+ smtp_require_tls = cfg.alerts.requireTLS;
+ }
+ // lib.optionalAttrs (cfg.alerts.username != null) {
+ smtp_auth_username = cfg.alerts.username;
+ }
+ // lib.optionalAttrs (cfg.alerts.passwordFile != null) {
+ smtp_auth_password_file = toString cfg.alerts.passwordFile;
+ };
+
+ route = {
+ receiver = "email";
+ # By alertname and host, so nine failing drives on one machine arrive as one
+ # mail rather than nine.
+ group_by = [ "alertname" "host" ];
+ group_wait = "1m";
+ group_interval = "10m";
+ repeat_interval = cfg.alerts.repeatInterval;
+ };
+
+ receivers = [{
+ name = "email";
+ email_configs = map
+ (addr: {
+ to = addr;
+ send_resolved = true;
+ })
+ cfg.alerts.to;
+ }];
+ }
+ cfg.alerts.settings;
+ };
};
# --------------------------------------------------------------------------------
@@ -481,6 +622,9 @@ in
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall (
optionals cfg.loki.enable [ ports.loki ]
++ optionals cfg.prometheus.enable [ ports.prometheus ]
+ # The Alertmanager UI is how you silence an alert. Note anyone who can reach it
+ # can silence anything -- same trust model as the Prometheus port above.
+ ++ optionals cfg.alerts.enable [ ports.alertmanager ]
++ (if cfg.proxy.enable then [ 80 443 ] else optionals cfg.grafana.enable [ ports.grafana ])
);
};