From a6ca97ca0eece7b1331f74c1874162d69acd7a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20D=C3=A9siles?= <1536672+cdesiles@users.noreply.github.com> Date: Sat, 30 May 2026 21:57:13 +0200 Subject: [PATCH] feat(samba_server): new role for SMB/CIFS shares Mirrors the nfs_server design: standalone tdbsam server, per-share access control (valid_users, write_list, force_user/group), optional guest fallback (map to guest = Bad User), UFW rules for ports 445/139, testparm-validated config, idempotent smbpasswd user creation. --- roles/samba_server/README.md | 72 ++++++++++++++++++++ roles/samba_server/defaults/main.yml | 64 +++++++++++++++++ roles/samba_server/handlers/main.yml | 6 ++ roles/samba_server/tasks/main.yml | 87 ++++++++++++++++++++++++ roles/samba_server/templates/smb.conf.j2 | 48 +++++++++++++ 5 files changed, 277 insertions(+) create mode 100644 roles/samba_server/README.md create mode 100644 roles/samba_server/defaults/main.yml create mode 100644 roles/samba_server/handlers/main.yml create mode 100644 roles/samba_server/tasks/main.yml create mode 100644 roles/samba_server/templates/smb.conf.j2 diff --git a/roles/samba_server/README.md b/roles/samba_server/README.md new file mode 100644 index 0000000..d34fe22 --- /dev/null +++ b/roles/samba_server/README.md @@ -0,0 +1,72 @@ +# Samba Server + +Minimal SMB/CIFS file sharing, mirroring the design of the `nfs_server` role. + +Security is assumed to come from the network (firewall + VPN). No Active +Directory, no Kerberos, no winbind. Standalone server, `tdbsam` backend. + +## In a nutshell + +**Supports:** + +- SMB2/SMB3 over TCP (port 445) and legacy NetBIOS (port 139) +- Per-share access control (`valid_users`, `write_list`, `force_user/group`) +- Optional guest fallback (`map to guest = Bad User`) +- UFW firewall configuration +- `testparm`-validated config before reload +- Idempotent user creation via `smbpasswd` + +**Limitations:** + +- No Active Directory / Kerberos integration +- Samba user accounts are only **created**, never updated. To rotate a + password, run `pdbedit -x ` first, then rerun the playbook. +- The matching system user (`/etc/passwd`) must already exist; this role + does not create UNIX accounts. + +## Inventory + +```yaml +# Bind only to private interfaces +samba_bind_interfaces_only: true +samba_interfaces: + - lo + - lan0 + - 192.168.1.161 + +# UNIX users must exist beforehand (e.g. via the `users` role +# or manual `useradd`). This role only manages the SMB password. +samba_users: + - username: alice + password: "{{ vault_alice_smb_password }}" + - username: bob + password: "{{ vault_bob_smb_password }}" + +samba_shares: + - name: photos + path: /mnt/andromeda/family-photos + comment: "Family photos" + read_only: false + valid_users: ["alice", "bob"] + write_list: ["alice"] + force_user: alice + force_group: users + + - name: public + path: /mnt/andromeda/public + comment: "Read-only public share" + guest_ok: true + read_only: true + +samba_server_firewall_allowed_sources: + - 192.168.1.0/24 + - 192.168.27.0/27 +``` + +See [`defaults/main.yml`](./defaults/main.yml) for all variables and defaults. + +## Resources + +- https://wiki.archlinux.org/title/Samba +- https://wiki.samba.org/index.php/Setting_up_Samba_as_a_Standalone_Server +- `man smb.conf`, `man smbpasswd`, `man pdbedit` diff --git a/roles/samba_server/defaults/main.yml b/roles/samba_server/defaults/main.yml new file mode 100644 index 0000000..b9794db --- /dev/null +++ b/roles/samba_server/defaults/main.yml @@ -0,0 +1,64 @@ +--- +# Global server identity +samba_workgroup: "WORKGROUP" +samba_server_string: "Samba Server" +samba_netbios_name: "{{ inventory_hostname | upper }}" + +# Map unknown users to guest (similar to NFS all_squash). +# "Never" disables guest fallback, "Bad User" maps unknown users to guest. +samba_map_to_guest: "Bad User" +samba_guest_account: "nobody" + +# Interfaces to bind samba listeners to. +# `bind interfaces only` is always enabled. If samba_interfaces is empty, +# samba binds to no interface and is effectively isolated. +samba_interfaces: [] +# Example: +# samba_interfaces: +# - lo +# - lan0 +# - 192.168.1.161 + +# Samba user accounts. The matching system user MUST already exist +# (created by another role or manually). The role only manages the +# samba password (smbpasswd) and is idempotent: existing users are +# not touched. To rotate a password, delete it first with +# `pdbedit -x ` then rerun the playbook. +samba_users: [] +# Example: +# samba_users: +# - username: alice +# password: "secret" + +# Shares +samba_shares: [] +# Example: +# samba_shares: +# - name: photos +# path: /mnt/andromeda/family-photos +# comment: "Family photos" +# browseable: true # default: true +# read_only: false # default: true +# guest_ok: false # default: false +# valid_users: ["alice"] # optional +# write_list: ["alice"] # optional +# force_user: alice # optional +# force_group: users # optional +# create_mask: "0664" # default: 0664 +# directory_mask: "0775" # default: 0775 +# manage_directory: false # default: false (do not create/chown the dir) +# extra_options: # optional, raw smb.conf key/values +# "veto files": "/.DS_Store/" + +samba_config_file: "/etc/samba/smb.conf" + +# smbd defaults to 445 (SMB) and 139 (NetBIOS Session) +samba_port_smb: 445 +samba_port_netbios: 139 + +samba_server_firewall_allowed_sources: + - 127.0.0.0/8 + +# OS-dependent service name +samba_service_name: >- + {{ (ansible_facts['os_family'] == 'Archlinux') | ternary('smb', 'smbd') }} diff --git a/roles/samba_server/handlers/main.yml b/roles/samba_server/handlers/main.yml new file mode 100644 index 0000000..b3532a0 --- /dev/null +++ b/roles/samba_server/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart samba + ansible.builtin.systemd: + name: "{{ samba_service_name }}" + state: restarted + daemon_reload: true diff --git a/roles/samba_server/tasks/main.yml b/roles/samba_server/tasks/main.yml new file mode 100644 index 0000000..6a7ca00 --- /dev/null +++ b/roles/samba_server/tasks/main.yml @@ -0,0 +1,87 @@ +--- +- name: Validate samba users have a password set + ansible.builtin.assert: + that: + - item.username is defined and item.username | length > 0 + - item.password is defined and item.password | length >= 8 + fail_msg: | + Each samba_users entry must define `username` and `password` (>=8 chars). + See roles/samba_server/defaults/main.yml for the expected schema. + loop: "{{ samba_users }}" + loop_control: + label: "{{ item.username | default('') }}" + no_log: true + +- name: Install samba + ansible.builtin.package: + name: samba + state: present + +- name: Configure samba + ansible.builtin.template: + src: smb.conf.j2 + dest: "{{ samba_config_file }}" + owner: root + group: root + mode: "0644" + validate: "testparm -s %s" + notify: Restart samba + +- name: Ensure share directories exist + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.force_user | default('root') }}" + group: "{{ item.force_group | default('root') }}" + mode: "{{ item.directory_mask | default('0775') }}" + loop: "{{ samba_shares }}" + loop_control: + label: "{{ item.name }}" + when: item.manage_directory | default(false) + +- name: Verify system users exist for samba accounts + ansible.builtin.getent: + database: passwd + key: "{{ item.username }}" + loop: "{{ samba_users }}" + loop_control: + label: "{{ item.username }}" + +- name: Check existing samba users + ansible.builtin.command: pdbedit -L + register: samba_existing_users + changed_when: false + failed_when: false + +- name: Add samba users + ansible.builtin.shell: | + set -o pipefail + (echo "{{ item.password }}"; echo "{{ item.password }}") | smbpasswd -s -a "{{ item.username }}" + args: + executable: /bin/bash + loop: "{{ samba_users }}" + loop_control: + label: "{{ item.username }}" + when: item.username not in (samba_existing_users.stdout | default('')) + changed_when: true + no_log: true + +- name: Systemd service for samba is started and enabled + ansible.builtin.systemd: + name: "{{ samba_service_name }}" + state: started + enabled: true + +- name: Setup firewall rules for samba + community.general.ufw: + rule: allow + src: "{{ item.0 }}" + port: "{{ item.1 }}" + proto: tcp + direction: in + comment: "Samba (SMB)" + loop: "{{ samba_server_firewall_allowed_sources | product([samba_port_smb, samba_port_netbios]) | list }}" + retries: 5 + delay: 2 + register: ufw_result + until: ufw_result is succeeded diff --git a/roles/samba_server/templates/smb.conf.j2 b/roles/samba_server/templates/smb.conf.j2 new file mode 100644 index 0000000..5460a23 --- /dev/null +++ b/roles/samba_server/templates/smb.conf.j2 @@ -0,0 +1,48 @@ +# {{ ansible_managed }} +[global] + workgroup = {{ samba_workgroup }} + server string = {{ samba_server_string }} + netbios name = {{ samba_netbios_name }} + server role = standalone server + security = user + passdb backend = tdbsam + map to guest = {{ samba_map_to_guest }} + guest account = {{ samba_guest_account }} + bind interfaces only = yes + interfaces = {{ samba_interfaces | join(' ') }} + log file = /var/log/samba/log.%m + max log size = 1000 + logging = file + disable netbios = no + dns proxy = no + +{% for share in samba_shares %} +[{{ share.name }}] + path = {{ share.path }} +{% if share.comment is defined %} + comment = {{ share.comment }} +{% endif %} + browseable = {{ share.browseable | default(true) | ternary('yes', 'no') }} + read only = {{ share.read_only | default(true) | ternary('yes', 'no') }} + guest ok = {{ share.guest_ok | default(false) | ternary('yes', 'no') }} +{% if share.valid_users is defined %} + valid users = {{ share.valid_users | join(' ') }} +{% endif %} +{% if share.write_list is defined %} + write list = {{ share.write_list | join(' ') }} +{% endif %} +{% if share.force_user is defined %} + force user = {{ share.force_user }} +{% endif %} +{% if share.force_group is defined %} + force group = {{ share.force_group }} +{% endif %} + create mask = {{ share.create_mask | default('0664') }} + directory mask = {{ share.directory_mask | default('0775') }} +{% if share.extra_options is defined %} +{% for k, v in share.extra_options.items() %} + {{ k }} = {{ v }} +{% endfor %} +{% endif %} + +{% endfor %}