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:
-
SMB users must already exist as system users. This module creates only the group, never the accounts. The password-setup service silently skips any
smbUserwith no matchingid, so a missing system account does not fail the build — it surfaces later as an authentication failure at connect time. Make sure every name insmbUsersalso has ausers.users.<name>somewhere in the host config. -
Rotating the password file does not re-set a live password. Idempotency comes from a
pdbedit -Lguard: the oneshot only callssmbpasswd -afor 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 apasswordFilehas 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:
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 = falsemeans the share is unreachable from other machines until you open 445/139 yourself. openFirewall = trueopens 445/139 on every interface. It maps to NixOS'sservices.samba.openFirewall, which is not scoped by theinterfacesoption —interfacesonly 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 keepopenFirewall = falseand add your own scoped rule, e.g.networking.firewall.interfaces.eth0.allowedTCPPorts = [ 445 139 ].
Design notes¶
nmbdis 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 Userwithguest 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/wantssamba-smbd.service, so the passdb is populated once smbd is up, andRemainAfterExitkeeps 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
);
};
};
}