Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions nixos/doc/manual/release-notes/rl-2511.section.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@

- [KMinion](https://github.com/redpanda-data/kminion), feature-rich Prometheus exporter for Apache Kafka. Available as [services.prometheus.exporters.kafka](options.html#opt-services.prometheus.exporters.kafka).

- [Beszel](https://beszel.dev), a lightweight server monitoring hub with historical data, docker stats, and alerts. Available as [`services.beszel.agent`](options.html#opt-services.beszel.agent.enable) and [`services.beszel.hub`](options.html#opt-services.beszel.hub.enable).

- [Spoolman](https://github.com/Donkie/Spoolman), a inventory management system for Filament spools. Available as [services.spoolman](#opt-services.spoolman.enable).

- [Temporal](https://temporal.io/), a durable execution platform that enables
Expand Down
2 changes: 2 additions & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,8 @@
./services/monitoring/apcupsd.nix
./services/monitoring/arbtt.nix
./services/monitoring/below.nix
./services/monitoring/beszel-agent.nix
./services/monitoring/beszel-hub.nix
./services/monitoring/bosun.nix
./services/monitoring/cadvisor.nix
./services/monitoring/certspotter.nix
Expand Down
119 changes: 119 additions & 0 deletions nixos/modules/services/monitoring/beszel-agent.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.services.beszel.agent;
in
{
meta.maintainers = with lib.maintainers; [
BonusPlay
arunoruto
];

options.services.beszel.agent = {
enable = lib.mkEnableOption "beszel agent";
package = lib.mkPackageOption pkgs "beszel" { };
openFirewall = (lib.mkEnableOption "") // {
description = "Whether to open the firewall port (default 45876).";
};

environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = ''
Environment variables for configuring the beszel-agent service.
This field will end up public in /nix/store, for secret values (such as `KEY`) use `environmentFile`.

See <https://www.beszel.dev/guide/environment-variables#agent> for available options.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File path containing environment variables for configuring the beszel-agent service in the format of an EnvironmentFile. See {manpage}`systemd.exec(5)`.
'';
};
extraPath = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = ''
Extra packages to add to beszel path (such as nvidia-smi or rocm-smi).
'';
};
};

config = lib.mkIf cfg.enable {
systemd.services.beszel-agent = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of the setup scripts and docs really seems to suggest that the agent wants a KEY/KEY_FILE of some sort. Since you've been dogfooding this for a while, I assume you set this in the environment file maybe?

Do you think it's important enough to warrant a separate option? Or at least a piece of documentation or description telling downstream module users to set it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it needs to be set (and is pretty much the only important config). However, it is technically a secret value, so KEY should be passed via here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure what you mean by "here", are you referring to the environment file? In any case, I really think it would be nice to communicate to the module user in some way that this piece of config really needs to be set. Either via a separate unset option keyFile that just sets the environment variable for them and makes the module system force them to configure it, via some piece of a description where the user is likely to see the notice, or via a meta.doc file.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, most of the config options are done through the environment variables. I just read-up on how to set it up and came with the following snipper:

environment = lib.mkOption {
  type = lib.types.submodule {
    freeformType = with lib.types; attrsOf anything;
    options = {
      EXTRA_FILESYSTEMS = lib.mkOption {
        type = lib.types.str;
        default = "";
        example = ''
          lib.strings.concatStringsSep "," [
            "sdb"
            "sdc1"
            "mmcblk0"
            "/mnt/network-share"
          ];
        '';
        description = ''
          Comma separated list of additional filesystems to monitor extra disks.
          See <https://beszel.dev/guide/additional-disks>.
        '';
      };
      KEY = lib.mkOption {
        type = lib.types.nullOr lib.types.str;
        default = null;
        description = ''
          Public SSH key(s) to use for authentication. Provided in the hub.
        '';
      };
      KEY_FILE = lib.mkOption {
        type = lib.types.nullOr lib.types.path;
        default = null;
        description = ''
          Public SSH key(s) from a file instead of an environment variable. Provided in the hub.
        '';
      };
      TOKEN = lib.mkOption {
        type = lib.types.nullOr lib.types.str;
        default = null;
        description = ''
          WebSocket registration token. Provided in the hub.
        '';
      };
      TOKEN_FILE = lib.mkOption {
        type = lib.types.nullOr lib.types.path;
        default = null;
        description = ''
          WebSocket token from a file instead of an environment variable. Provided in the hub.
        '';
      };
    };
  };
  default = { };
  description = ''
    Environment variables for configuring the beszel-agent service.
    This field will end up public in /nix/store, for secret values use `environmentFile`.
  '';
};

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think even having TOKEN and KEY options is a anti-pattern. We shouldn't encourage users to store secrets using plaintext in nix-store. If they do want, the can do that (via environment option).
We could add separate KEY_FILE and TOKEN_FILE options. wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably no need to expose EXTRA_FILESYSTEMS unless we have a sane default for it or expect most users to want to set it.

Copy link
Member

@h7x4 h7x4 Oct 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a second note, any chance the key could be provisioned programmatically somehow? Or is it one of those things where you just have to use the GUI? It would be kinda nice to have a nixos test for all of this...

Edit: found https://github.com/henrygd/beszel/blob/1e32d136506f44f191164c857f730b675adb3812/internal/hub/hub.go#L271 and https://github.com/henrygd/beszel/blob/1e32d136506f44f191164c857f730b675adb3812/internal/hub/hub.go#L224-L227, I'll see if I can cook up something (unless anyone else wants to have a go at it)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an ssh pubkey that is visible from hub's web-ui.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably no need to expose EXTRA_FILESYSTEMS unless we have a sane default for it or expect most users to want to set it.

So just the two _FILE options

On a second note, any chance the key could be provisioned programmatically somehow? Or is it one of those things where you just have to use the GUI? It would be kinda nice to have a nixos test for all of this...

There is a way to do so via the CLI:

Usage: beszel-agent [command] [flags]

Commands:
  health    Check if the agent is running
  help      Display this help message
  update    Update to the latest version
  version   Display the version

Flags:
  -key string
        Public key(s) for SSH authentication
  -listen string
        Address or port to listen on

But I am not sure if it is feasible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be where you give the agent the key from the hub, but I was looking for where to get the key from the hub in the first place. Think I found it though :)

description = "Beszel Server Monitoring Agent";

wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];

environment = cfg.environment;
path =
cfg.extraPath
++ lib.optionals (builtins.elem "nvidia" config.services.xserver.videoDrivers) [
(lib.getBin config.hardware.nvidia.package)
]
++ lib.optionals (builtins.elem "amdgpu" config.services.xserver.videoDrivers) [
(lib.getBin pkgs.rocmPackages.rocm-smi)
]
++ lib.optionals (builtins.elem "intel" config.services.xserver.videoDrivers) [
(lib.getBin pkgs.intel-gpu-tools)
];

serviceConfig = {
ExecStart = ''
${cfg.package}/bin/beszel-agent
'';

EnvironmentFile = cfg.environmentFile;

# adds ability to monitor docker/podman containers
SupplementaryGroups =
lib.optionals config.virtualisation.docker.enable [ "docker" ]
++ lib.optionals (
config.virtualisation.podman.enable && config.virtualisation.podman.dockerSocket.enable
) [ "podman" ];

DynamicUser = true;
User = "beszel-agent";
LockPersonality = true;
NoNewPrivileges = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = "strict";
ProtectHome = "read-only";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
Restart = "on-failure";
RestartSec = "30s";
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallErrorNumber = "EPERM";
SystemCallFilter = [ "@system-service" ];
Type = "simple";
UMask = 27;
};
};

networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [
(
if (builtins.hasAttr "PORT" cfg.environment) then
(lib.strings.toInt cfg.environment.PORT)
else
45876
)
];
};
}
114 changes: 114 additions & 0 deletions nixos/modules/services/monitoring/beszel-hub.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.services.beszel.hub;
in
{
meta.maintainers = with lib.maintainers; [
BonusPlay
arunoruto
];

options.services.beszel.hub = {
enable = lib.mkEnableOption "beszel hub";

package = lib.mkPackageOption pkgs "beszel" { };

host = lib.mkOption {
default = "127.0.0.1";
type = lib.types.str;
example = "0.0.0.0";
description = "Host or address this beszel hub listens on.";
};
port = lib.mkOption {
default = 8090;
type = lib.types.port;
example = 3002;
description = "Port for this beszel hub to listen on.";
};

dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/beszel-hub";
description = "Data directory of beszel-hub.";
};

environment = lib.mkOption {
type = with lib.types; attrsOf str;
default = { };
example = {
DISABLE_PASSWORD_AUTH = "true";
};
description = ''
Environment variables passed to the systemd service.
See <https://www.beszel.dev/guide/environment-variables#hub> for available options.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Environment file to be passed to the systemd service.
Useful for passing secrets to the service to prevent them from being
world-readable in the Nix store. See {manpage}`systemd.exec(5)`.
'';
};
};

config = lib.mkIf cfg.enable {
systemd.services.beszel-hub = {
description = "Beszel Server Monitoring Web App";

wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
environment = cfg.environment;

serviceConfig = {
ExecStartPre = [
"${cfg.package}/bin/beszel-hub migrate up"
"${cfg.package}/bin/beszel-hub history-sync"
];
ExecStart = ''
${cfg.package}/bin/beszel-hub serve --http='${cfg.host}:${toString cfg.port}'
'';

EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
WorkingDirectory = cfg.dataDir;
StateDirectory = baseNameOf cfg.dataDir;
RuntimeDirectory = baseNameOf cfg.dataDir;
ReadWritePaths = cfg.dataDir;

DynamicUser = true;
Copy link
Member

@h7x4 h7x4 Oct 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if it would make sense to have this actually have a real system user. When running beszel-hub superuser create ..., the command just assumes that it's going to run as it is in cwd, and creates a new database right there. With a real user, the sysadmin could at least do sudo -u beszel-hub beszel-hub --dir /var/lib/beszel-hub/beszel_data superuser create ...

The alternative would be to put some sort of wrapper script in the environment that runs the commands as the service. There is a fancy new feature in systemd v258 systemd-analyze unit-shell that we might be able to use in a script that would run the commands with the environment of the unit. Using this tool would probably be quite experimental (for better or for worse). Both nsenter and sudo -u could be alternatives for the script, but I don't think they would work while the service is shut down. nsenter inherently so, and sudo -u because the user doesn't exist.

(See also nextcloud)

occ = pkgs.writeShellApplication {
name = "nextcloud-occ";
text =
let
command = ''
${lib.getExe' pkgs.coreutils "env"} \
NEXTCLOUD_CONFIG_DIR="${datadir}/config" \
${phpCli} \
occ "$@"
'';
in
''
cd ${webroot}
# NOTE: This is templated at eval time
requiresRuntimeSystemdCredentials=${lib.boolToString requiresRuntimeSystemdCredentials}
# NOTE: This wrapper is both used in the internal nextcloud service units
# and by users outside a service context for administration. As such,
# when there's an existing CREDENTIALS_DIRECTORY, we inherit it for use
# in the nix_read_secret() php function.
# When there's no CREDENTIALS_DIRECTORY we try to use systemd-run to
# load the credentials just as in a service unit.
# NOTE: If there are no credentials that are required at runtime then there's no need
# to load any credentials.
if [[ $requiresRuntimeSystemdCredentials == true && -z "''${CREDENTIALS_DIRECTORY:-}" ]]; then
exec ${lib.getExe' config.systemd.package "systemd-run"} \
${
lib.escapeShellArgs (
map (credential: "--property=LoadCredential=${credential}") runtimeSystemdCredentials
)
} \
--uid=nextcloud \
--same-dir \
--pty \
--wait \
--collect \
--service-type=exec \
--setenv OC_PASS \
--setenv NC_PASS \
--quiet \
${command}
elif [[ "$USER" != nextcloud ]]; then
if [[ -x /run/wrappers/bin/sudo ]]; then
exec /run/wrappers/bin/sudo \
--preserve-env=CREDENTIALS_DIRECTORY \
--preserve-env=OC_PASS \
--preserve-env=NC_PASS \
--user=nextcloud \
${command}
else
exec ${lib.getExe' pkgs.util-linux "runuser"} \
--whitelist-environment=CREDENTIALS_DIRECTORY \
--whitelist-environment=OC_PASS \
--whitelist-environment=NC_PASS \
--user=nextcloud \
${command}
fi
else
exec ${command}
fi
'';
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're at it, did you create your superuser through the cli tool, or is there some other way around?

User = "beszel-hub";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = "strict";
ProtectHome = "read-only";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
DevicePolicy = "closed";
Restart = "on-failure";
RestartSec = "30s";
RestrictRealtime = true;
RestrictSUIDSGID = true;
RestrictNamespaces = true;
SystemCallArchitectures = "native";
SystemCallErrorNumber = "EPERM";
SystemCallFilter = [ "@system-service" ];
UMask = 27;
};
};
};
}
1 change: 1 addition & 0 deletions nixos/tests/all-tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ in
beanstalkd = runTest ./beanstalkd.nix;
bees = runTest ./bees.nix;
benchexec = runTest ./benchexec.nix;
beszel = runTest ./beszel.nix;
binary-cache = runTest {
imports = [ ./binary-cache.nix ];
_module.args.compression = "zstd";
Expand Down
119 changes: 119 additions & 0 deletions nixos/tests/beszel.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
{ pkgs, lib, ... }:
{
name = "beszel";
meta.maintainers = with lib.maintainers; [ h7x4 ];

nodes = {
hubHost =
{ config, pkgs, ... }:
{
virtualisation.vlans = [ 1 ];

systemd.network.networks."01-eth1" = {
name = "eth1";
networkConfig.Address = "10.0.0.1/24";
};

networking = {
useNetworkd = true;
useDHCP = false;
};

services.beszel.hub = {
enable = true;
host = "10.0.0.1";
};

networking.firewall.allowedTCPPorts = [
config.services.beszel.hub.port
];

environment.systemPackages = [
config.services.beszel.hub.package
];
};

agentHost =
{ config, pkgs, ... }:
{
virtualisation.vlans = [ 1 ];

systemd.network.networks."01-eth1" = {
name = "eth1";
networkConfig.Address = "10.0.0.2/24";
};

networking = {
useNetworkd = true;
useDHCP = false;
};

environment.systemPackages = with pkgs; [ jq ];

specialisation."agent".configuration = {
services.beszel.agent = {
enable = true;
environment.HUB_URL = "http://10.0.0.1:8090";
environment.KEY_FILE = "/var/lib/beszel-agent/id_ed25519.pub";
environment.TOKEN_FILE = "/var/lib/beszel-agent/token";
openFirewall = true;
};
};
};
};

testScript =
{ nodes, ... }:
let
hubCfg = nodes.hubHost.services.beszel.hub;
agentCfg = nodes.agentHost.specialisation."agent".configuration.services.beszel.agent;
in
''
import json

start_all()

with subtest("Start hub"):
hubHost.wait_for_unit("beszel-hub.service")
hubHost.wait_for_open_port(${toString hubCfg.port}, "${toString hubCfg.host}")

with subtest("Register user"):
agentHost.succeed('curl -f --json \'${
builtins.toJSON {
email = "admin@example.com";
password = "password";
}
}\' "${agentCfg.environment.HUB_URL}/api/beszel/create-user"')
user = json.loads(agentHost.succeed('curl -f --json \'${
builtins.toJSON {
identity = "admin@example.com";
password = "password";
}
}\' ${agentCfg.environment.HUB_URL}/api/collections/users/auth-with-password').strip())

with subtest("Install agent credentials"):
agentHost.succeed("mkdir -p \"$(dirname '${agentCfg.environment.KEY_FILE}')\" \"$(dirname '${agentCfg.environment.TOKEN_FILE}')\"")
sshkey = agentHost.succeed(f"curl -H 'Authorization: {user["token"]}' -f ${agentCfg.environment.HUB_URL}/api/beszel/getkey | jq -r .key").strip()
utoken = agentHost.succeed(f"curl -H 'Authorization: {user["token"]}' -f ${agentCfg.environment.HUB_URL}/api/beszel/universal-token | jq -r .token").strip()
agentHost.succeed(f"echo '{sshkey}' > '${agentCfg.environment.KEY_FILE}'")
agentHost.succeed(f"echo '{utoken}' > '${agentCfg.environment.TOKEN_FILE}'")

with subtest("Register agent in hub"):
agentHost.succeed(f'curl -H \'Authorization: {user["token"]}\' -f --json \'{${
builtins.toJSON {
"host" = "10.0.0.2";
"name" = "agent";
"pkey" = "{sshkey}";
"port" = "45876";
"tkn" = "{utoken}";
"users" = ''{user['record']['id']}'';
}
}}\' "${agentCfg.environment.HUB_URL}/api/collections/systems/records"')

with subtest("Start agent"):
agentHost.succeed("/run/current-system/specialisation/agent/bin/switch-to-configuration switch")
agentHost.wait_for_unit("beszel-agent.service")
agentHost.wait_until_succeeds("journalctl -eu beszel-agent --grep 'SSH connection established'")
agentHost.wait_until_succeeds(f'curl -H \'Authorization: {user["token"]}\' -f ${agentCfg.environment.HUB_URL}/api/collections/systems/records | grep agentHost')
'';
}
Loading
Loading