Skip to content

samba-shared-folder

Modules

A single declarative Samba (SMB) share for a NixOS host — with the one thing NixOS can't declare, SMB passwords, bridged in via a guarded oneshot service.

The problem

NixOS can declare a Samba share end to end: the share path, permissions, firewall, valid users, everything. But there is no declarative way to set an SMB password. smbpasswd writes to an on-disk password database (a tdb) at runtime; there is no services.samba.users.<name>.password option. So a share you declare purely in Nix is unusable until someone SSHes in and runs smbpasswd by hand.

This module closes that gap by running smbpasswd for you from a secret file, idempotently, on every activation.

The two traps

Reading the Nix won't tell you either of these:

  1. SMB users must already exist as system users. This module creates only the group, never the accounts. The password-setup service silently skips any smbUser with no matching id, so a missing system account does not fail the build — it surfaces later as an authentication failure at connect time. Make sure every name in smbUsers also has a users.users.<name> somewhere in the host config.

  2. Rotating the password file does not re-set a live password. Idempotency comes from a pdbedit -L guard: the oneshot only calls smbpasswd -a for users not already in the passdb. This is deliberate — it keeps activation from touching passwords on every rebuild — but it means changing the contents of a passwordFile has no effect on an already-provisioned user. To actually change a live password, remove the user from the passdb first and let the service re-add them:

pdbedit -x -u alice     # drop alice from the SMB passdb
systemctl restart setup-smb-passwords

Usage

Import default.nix as a NixOS module and enable it:

{
  imports = [ ./samba-shared-folder ];

  # each SMB user must ALSO be a real system user
  users.users.alice = { isNormalUser = true; /* ... */ };

  modules.services.shared-folder = {
    enable = true;
    smbUsers.alice.passwordFile = "/run/secrets/alice-smb";
  };
}

Then open the firewall — either a scoped rule like networking.firewall.interfaces.eth0.allowedTCPPorts = [ 445 139 ] (preferred) or the module's all-interfaces openFirewall = true — and connect from a client at \\your-host\shared (or smb://your-host/shared) as alice.

Options

Option Default Purpose
enable false Turn the share on.
sharePath /srv/shared Folder on disk, created 0770 root:<group>.
shareName shared Share name clients see.
group shared Owning POSIX group; files are force grouped to it so members see each other's writes.
workgroup WORKGROUP SMB workgroup.
interfaces [] Subnets/interfaces to bind to. Empty = all; when set, bind interfaces only is enabled.
openFirewall false Opt-in: open SMB ports 445/139. Opens them on all interfaces regardless of interfaces — see Security notes.
smbUsers {} Attrset of <user>.passwordFile. At least one is required.

The passwordFile should live outside the Nix store (an agenix/sops secret, or a path under /run) — anything in the store is world-readable.

Security notes

  • The firewall stays closed by default. openFirewall = false means the share is unreachable from other machines until you open 445/139 yourself.
  • openFirewall = true opens 445/139 on every interface. It maps to NixOS's services.samba.openFirewall, which is not scoped by the interfaces option — interfaces only controls the addresses smbd binds to, not the firewall. On a laptop that joins untrusted Wi-Fi, or any host with a public WAN interface, that exposes an authenticated SMB server to that network. SMB has a long history of pre-auth and auth-bypass CVEs, so on multi-homed or public-facing hosts keep openFirewall = false and add your own scoped rule, e.g. networking.firewall.interfaces.eth0.allowedTCPPorts = [ 445 139 ].

Design notes

  • nmbd is disabled. No NetBIOS name broadcast, so clients connect by hostname or IP rather than by browsing the network neighborhood. One fewer service and one fewer open port.
  • map to guest = Bad User with guest ok = no. Unknown usernames are mapped to guest and then rejected by the share (guests aren't valid users). The net effect is that a bogus username is denied without a password prompt — the mapping suppresses the prompt, the share still refuses access.
  • The setup service orders after/wants samba-smbd.service, so the passdb is populated once smbd is up, and RemainAfterExit keeps it "active" so it doesn't re-run needlessly.

Source

modules/samba-shared-folder/default.nix
# samba-shared-folder — a single declarative SMB share, with imperative
# passwords bridged in via a guarded oneshot.
#
# NixOS can declare a Samba share fully, but it CANNOT declare SMB passwords:
# smbpasswd writes to an on-disk passdb (tdb) at runtime, there is no
# `services.samba.users.<name>.password` knob. This module bridges that gap.
#
# Import it into a host config and set at least one smbUser:
#
#   modules.services.shared-folder = {
#     enable = true;
#     smbUsers.alice.passwordFile = "/run/secrets/alice-smb";
#   };
#
# See README.md for the two traps this pattern exists to work around.
{
  config,
  lib,
  pkgs,
  ...
}:
with lib;
let
  cfg = config.modules.services.shared-folder;
in
{
  options.modules.services.shared-folder = {
    enable = mkEnableOption "SMB shared folder";

    sharePath = mkOption {
      type = types.str;
      default = "/srv/shared";
      description = "Path to the shared folder on disk.";
    };

    shareName = mkOption {
      type = types.str;
      default = "shared";
      description = "Name of the SMB share as seen by clients (\\\\host\\<shareName>).";
    };

    group = mkOption {
      type = types.str;
      default = "shared";
      description = "POSIX group that owns the shared folder. Files are force-grouped to it so all share members can read each other's writes.";
    };

    workgroup = mkOption {
      type = types.str;
      default = "WORKGROUP";
      description = "SMB workgroup name.";
    };

    interfaces = mkOption {
      type = types.listOf types.str;
      default = [ ];
      description = "Interfaces/subnets to bind Samba to. Empty = all interfaces. When set, 'bind interfaces only' is enabled.";
      example = [
        "192.168.1.0/24"
        "10.0.0.0/24"
      ];
    };

    openFirewall = mkOption {
      type = types.bool;
      default = false;
      description = ''
        Whether to open the SMB firewall ports (445/139). Off by default: the
        share is unreachable off-box until you open the firewall.

        NOTE: this uses NixOS's `services.samba.openFirewall`, which opens the
        ports on ALL interfaces — it is NOT scoped by the `interfaces` option
        above (that only controls which addresses smbd binds to). On a
        multi-homed or public-facing host, prefer leaving this `false` and
        adding your own interface-scoped firewall rules
        (`networking.firewall.interfaces.<iface>.allowedTCPPorts = [ 445 139 ]`).
      '';
    };

    smbUsers = mkOption {
      type = types.attrsOf (
        types.submodule {
          options = {
            passwordFile = mkOption {
              type = types.path;
              description = "Path to a file whose contents are this user's SMB password. Keep it out of the Nix store (e.g. an agenix/sops secret or a /run path).";
            };
          };
        }
      );
      default = { };
      description = ''
        SMB users and their password-file paths.

        IMPORTANT: each name here must ALSO be an existing system user
        (users.users.<name>). This module creates only the group, not the
        accounts. A name with no matching system user is silently skipped and
        surfaces later as an auth failure, never a build error.
      '';
      example = literalExpression ''
        {
          alice.passwordFile = "/run/secrets/alice-smb";
          bob.passwordFile = "/run/secrets/bob-smb";
        }
      '';
    };
  };

  config = mkIf cfg.enable {
    assertions = [
      {
        assertion = cfg.smbUsers != { };
        message = "modules.services.shared-folder: at least one smbUser must be configured";
      }
    ];

    users.groups.${cfg.group} = { };

    systemd.tmpfiles.rules = [
      "d ${cfg.sharePath} 0770 root ${cfg.group} - -"
    ];

    services.samba = {
      enable = true;
      openFirewall = cfg.openFirewall;
      # No NetBIOS name service: clients connect by hostname/IP, not by
      # browsing the "network neighborhood".
      nmbd.enable = false;
      settings = {
        global = {
          workgroup = cfg.workgroup;
          security = "user";
          # Map unknown users to guest, then reject them (guest ok = no on the
          # share). Net effect: bogus usernames get no password prompt AND no
          # access — the mapping suppresses the prompt, the share denies guests.
          "map to guest" = "Bad User";
          "server string" = "${config.networking.hostName} Shared Folder";
        }
        // optionalAttrs (cfg.interfaces != [ ]) {
          interfaces = concatStringsSep " " cfg.interfaces;
          "bind interfaces only" = "yes";
        };
        ${cfg.shareName} = {
          path = cfg.sharePath;
          browsable = "yes";
          writable = "yes";
          "guest ok" = "no";
          "valid users" = concatStringsSep "," (attrNames cfg.smbUsers);
          "force group" = cfg.group;
          "create mask" = "0660";
          "directory mask" = "0770";
        };
      };
    };

    # NixOS has no declarative SMB passwords, so bridge the imperative
    # smbpasswd with a oneshot. Idempotency comes from the `pdbedit -L` guard:
    # a user already in the passdb is left alone, so this runs at most once per
    # user. That also means rotating a passwordFile does NOT re-set a live
    # password — see README for how to force a reset.
    systemd.services.setup-smb-passwords = mkIf (cfg.smbUsers != { }) {
      description = "Set up Samba passwords from secret files";
      after = [ "samba-smbd.service" ];
      wants = [ "samba-smbd.service" ];
      wantedBy = [ "multi-user.target" ];
      path = [ config.services.samba.package ];
      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
      };
      script = concatStringsSep "\n" (
        mapAttrsToList (user: userCfg: ''
          if id ${escapeShellArg user} &>/dev/null; then
            if ! pdbedit -L -u ${escapeShellArg user} &>/dev/null; then
              password=$(cat ${escapeShellArg (toString userCfg.passwordFile)})
              printf '%s\n%s\n' "$password" "$password" | smbpasswd -a -s ${escapeShellArg user}
              echo "Added Samba user: ${user}"
            fi
          else
            echo "User ${user} does not exist, skipping"
          fi
        '') cfg.smbUsers
      );
    };
  };
}