diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index d02e61041566..4f3a9d0d9a80 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -12,6 +12,8 @@ - Create the first release note entry in this section! +- [Nezha](https://github.com/nezhahq/nezha), a self-hosted, lightweight server and website monitoring and O&M tool. Available as [services.nezha](#opt-services.nezha.enable). + - [FlapAlerted](https://github.com/Kioubit/FlapAlerted), detects BGP flapping events and provides statistics based on BGP update messages. Available as [services.flap-alerted](#opt-services.flap-alerted.enable). ## Backward Incompatibilities {#sec-release-26.11-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 3963215b813b..83a7574f5a52 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1049,6 +1049,7 @@ ./services/monitoring/nagios.nix ./services/monitoring/netdata.nix ./services/monitoring/nezha-agent.nix + ./services/monitoring/nezha.nix ./services/monitoring/ocsinventory-agent.nix ./services/monitoring/opentelemetry-collector.nix ./services/monitoring/osquery.nix diff --git a/nixos/modules/services/monitoring/nezha.nix b/nixos/modules/services/monitoring/nezha.nix new file mode 100644 index 000000000000..b4ed76b031e7 --- /dev/null +++ b/nixos/modules/services/monitoring/nezha.nix @@ -0,0 +1,179 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.nezha; + + # nezha uses yaml as the configuration file format. + # Since we need to use jq to update the content, so here we generate json + settingsFormat = pkgs.formats.json { }; + configFile = settingsFormat.generate "config.json" cfg.settings; +in +{ + meta.maintainers = with lib.maintainers; [ moraxyc ]; + options = { + services.nezha = { + enable = lib.mkEnableOption "Nezha Monitoring"; + + package = lib.mkPackageOption pkgs "nezha" { }; + + debug = lib.mkEnableOption "verbose log"; + + settings = lib.mkOption { + description = '' + Generate to {file}`config.yaml` as a Nix attribute set. + Check the [guide](https://nezha.wiki/en_US/guide/dashboard.html) + for possible options. + ''; + type = lib.types.submodule { + freeformType = settingsFormat.type; + + options = { + listenhost = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Host on which the nezha web interface and grpc should listen. + ''; + }; + listenport = lib.mkOption { + type = lib.types.port; + default = 8008; + description = '' + Port on which the nezha web interface and grpc should listen. + ''; + }; + + }; + }; + }; + + mutableConfig = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether the config.yaml is writable by Nezha. + + If this option is disabled, changes on the web interface won't + be possible. If an config.yaml is present, it will be overwritten. + ''; + }; + + jwtSecretFile = lib.mkOption { + type = lib.types.path; + default = null; + description = '' + Path to the file containing the secret to sign web requests using JSON Web Tokens. + ''; + }; + + agentSecretFile = lib.mkOption { + type = lib.types.path; + default = null; + description = '' + Path to the file containing the secret used by agents to connect. + ''; + }; + + extraThemes = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + example = lib.literalExpression "[ pkgs.nezha-theme-nazhua ]"; + description = '' + A list of additional themes. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + services.nezha.settings.debug = cfg.debug; + + systemd.services.nezha = { + serviceConfig = { + Restart = "on-failure"; + StateDirectory = "nezha"; + RuntimeDirectory = "nezha"; + ConfigurationDirectory = "nezha"; + WorkingDirectory = "/var/lib/nezha"; + ReadWritePaths = [ + "/var/lib/nezha" + "/etc/nezha" + ]; + + LoadCredential = [ + "jwt-secret:${cfg.jwtSecretFile}" + "agent-secret:${cfg.agentSecretFile}" + ]; + + # Hardening + ProcSubset = "pid"; + DynamicUser = true; + RemoveIPC = true; + LockPersonality = true; + ProtectClock = true; + MemoryDenyWriteExecute = true; + PrivateUsers = cfg.settings.listenport >= 1024; # incompatible with CAP_NET_BIND_SERVICE + ProtectHostname = true; + RestrictSUIDSGID = true; + CapabilityBoundingSet = lib.optionalString (cfg.settings.listenport < 1024) "CAP_NET_BIND_SERVICE"; + AmbientCapabilities = lib.optionalString (cfg.settings.listenport < 1024) "CAP_NET_BIND_SERVICE"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RestrictNamespaces = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + UMask = "0066"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ] + ++ lib.optional (cfg.settings ? tsdb) "mincore"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + PrivateDevices = "yes"; + + ExecStart = + let + package = cfg.package.override { withThemes = cfg.extraThemes; }; + in + ''${lib.getExe package} -c "''${CONFIGURATION_DIRECTORY}"/config.yaml -db "''${STATE_DIRECTORY}"/sqlite.db''; + }; + enableStrictShellChecks = true; + startLimitIntervalSec = 10; + startLimitBurst = 3; + preStart = '' + cp "${configFile}" "''${RUNTIME_DIRECTORY}"/new + ${lib.getExe pkgs.jq} \ + --arg jwt_secret "$(<"''${CREDENTIALS_DIRECTORY}"/jwt-secret)" \ + --arg agent_secret "$(<"''${CREDENTIALS_DIRECTORY}"/agent-secret)" \ + '. + { jwtsecretkey: $jwt_secret, agentsecretkey: $agent_secret }' \ + < "''${RUNTIME_DIRECTORY}"/new > "''${RUNTIME_DIRECTORY}"/tmp + mv "''${RUNTIME_DIRECTORY}"/tmp "''${RUNTIME_DIRECTORY}"/new + + ${lib.optionalString cfg.mutableConfig '' + [ -e "''${CONFIGURATION_DIRECTORY}"/config.yaml ] && \ + ${lib.getExe pkgs.yj} < "''${CONFIGURATION_DIRECTORY}"/config.yaml > "''${RUNTIME_DIRECTORY}"/old && \ + ${lib.getExe pkgs.jq} -s '.[0] * .[1]' \ + "''${RUNTIME_DIRECTORY}"/old "''${RUNTIME_DIRECTORY}"/new > "''${RUNTIME_DIRECTORY}"/tmp + [ -e "''${RUNTIME_DIRECTORY}"/old ] && rm "''${RUNTIME_DIRECTORY}"/old + [ -e "''${RUNTIME_DIRECTORY}"/tmp ] && mv "''${RUNTIME_DIRECTORY}"/tmp "''${RUNTIME_DIRECTORY}"/new + ''} + mv "''${RUNTIME_DIRECTORY}"/new "''${CONFIGURATION_DIRECTORY}"/config.yaml + ''; + wantedBy = [ "multi-user.target" ]; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index a5d820bd50f0..b838d2d8cff4 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1094,6 +1094,7 @@ in nextflow = runTestOn [ "x86_64-linux" ] ./nextflow.nix; nextjs-ollama-llm-ui = runTest ./web-apps/nextjs-ollama-llm-ui.nix; nexus = runTest ./nexus.nix; + nezha = runTest ./nezha.nix; # TODO: Test nfsv3 + Kerberos nfs3 = handleTest ./nfs { version = 3; }; nfs4 = handleTest ./nfs { version = 4; }; diff --git a/nixos/tests/nezha.nix b/nixos/tests/nezha.nix new file mode 100644 index 000000000000..924cfc1d8300 --- /dev/null +++ b/nixos/tests/nezha.nix @@ -0,0 +1,114 @@ +{ lib, pkgs, ... }: +let + agent_host = "agent.test"; + dashboard_host = "dashboard.test"; + + agentSecret = pkgs.writeText "fakeagentsecret" "fakeagentsecret"; + + hosts = { + "${agent_host}" = "192.168.0.2"; + "${dashboard_host}" = "192.168.0.1"; + }; + hostsEntries = lib.mapAttrs' (k: v: { + name = v; + value = lib.singleton k; + }) hosts; +in +{ + name = "nezha"; + + meta.maintainers = with lib.maintainers; [ moraxyc ]; + + containers = { + agent = _: { + networking = { + hostName = builtins.elemAt (lib.splitString "." agent_host) 0; + domain = builtins.elemAt (lib.splitString "." agent_host) 1; + firewall.enable = false; + hosts = hostsEntries; + useDHCP = false; + interfaces.eth1.ipv4.addresses = lib.singleton { + address = hosts."${agent_host}"; + prefixLength = 24; + }; + }; + services.nezha-agent = { + enable = true; + debug = true; + genUuid = true; + settings = { + server = hosts."${dashboard_host}" + ":80"; + }; + clientSecretFile = agentSecret; + }; + }; + + dashboard = + { pkgs, ... }: + { + networking = { + firewall.enable = false; + hosts = hostsEntries; + useDHCP = false; + interfaces.eth1.ipv4.addresses = lib.singleton { + address = hosts."${dashboard_host}"; + prefixLength = 24; + }; + }; + services.nezha = { + enable = true; + debug = true; + settings = { + listenhost = "0.0.0.0"; + # Test CAP_NET_BIND_SERVICE + listenport = 80; + }; + mutableConfig = true; + jwtSecretFile = pkgs.writeText "fakejwt" "fakejwt"; + agentSecretFile = agentSecret; + }; + }; + }; + + testScript = '' + import json + + with subtest("Wait for services and network"): + dashboard.wait_for_unit("nezha.service") + agent.wait_for_unit("nezha-agent.service") + dashboard.wait_for_open_port(80) + dashboard.wait_for_unit("network.target") + agent.wait_for_unit("network.target") + agent.succeed("curl --fail --max-time 10 http://dashboard.test/") + + with subtest("Test mutableConfig"): + dashboard.succeed("systemctl stop nezha") + dashboard.succeed(""" + echo '{"sitename": "Nezha on NixOS"}' > /etc/nezha/config.yaml + """) + dashboard.succeed("systemctl start nezha") + dashboard.wait_for_unit("nezha.service") + dashboard.wait_for_open_port(80) + result = json.loads(agent.succeed(""" + curl --fail -X POST --json '{ "username": "admin", "password": "admin"}' \ + 'http://dashboard.test/api/v1/login' + """)) + token = result['data']['token'] + result = json.loads(agent.succeed(f""" + curl --fail -X GET --header 'Authorization: Bearer {token}' \ + 'http://dashboard.test/api/v1/setting' + """)) + assert "Nezha on NixOS" == result['data']['config']['site_name'] + + with subtest("Verify connection and uuid"): + uuid = agent.succeed( + "${lib.getExe' pkgs.util-linux "uuidgen"} --md5 -n @dns -N ${agent_host}" + ) + # remove unprintable characters + uuid = "".join([char for char in uuid if char.isprintable()]) + agent.wait_until_succeeds(f""" + curl --fail -X GET --header 'Authorization: Bearer {token}' \ + 'http://dashboard.test/api/v1/server' | grep {uuid} + """) + ''; +} diff --git a/pkgs/by-name/ne/nezha/package.nix b/pkgs/by-name/ne/nezha/package.nix index 64b675fc5db4..5239f9eb1378 100644 --- a/pkgs/by-name/ne/nezha/package.nix +++ b/pkgs/by-name/ne/nezha/package.nix @@ -7,6 +7,7 @@ dbip-country-lite, formats, nix-update-script, + nixosTests, nezha-theme-admin, nezha-theme-user, withThemes ? [ ], @@ -114,6 +115,9 @@ buildGoModule (finalAttrs: { passthru = { updateScript = nix-update-script { }; + tests = { + inherit (nixosTests) nezha; + }; }; meta = {