Compare commits

...

9 Commits

Author SHA1 Message Date
Clément Désiles 2f3eebd422 feat: add metabase role 2026-06-03 10:01:00 +02:00
Clément Désiles d976a9d701 fix: cleanup examples 2026-05-31 22:42:12 +02:00
Clément Désiles e74fffd5fc refacto: move inventory examples to a dedicated dir 2026-05-31 22:34:31 +02:00
Clément Désiles 30dfb9ee8b feat(immich): support read-only external libraries
Add immich_external_libraries variable to mount host paths into the
server container, intended for use with Immich's External Libraries
feature. Mounts are read-only; the in-container mount_path must be
used when registering the library in the Immich UI.
2026-05-30 23:39:17 +02:00
Clément Désiles b0324cf3fe refactor: hoist OS-specific package names to role defaults
- nfs_server: nfs_package_name (nfs-utils / nfs-kernel-server)
- wireguard: wireguard_package_name (wireguard-tools / wireguard)
- tooling:   tooling_dig_package, tooling_netcat_package

Also fix tooling role structure: move tooling.yml to tasks/main.yml so
the role is actually invokable via 'role: tooling' (defaults/main.yml
is auto-loaded), and collapse the 10 individual package tasks into a
single list-based install.
2026-05-30 21:57:20 +02:00
Clément Désiles a6ca97ca0e 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.
2026-05-30 21:57:13 +02:00
Clément Désiles b2a66099aa fix(immich): prevent client SocketTimeoutException on large uploads
Add missing nginx directives recommended by Immich docs for mobile
photo backup over slow links:

- http2 on: multiplex parallel asset uploads from the mobile app
- client_body_timeout / send_timeout / keepalive_timeout 600s: cover
  the client<->nginx leg (default 60s matched the Android timeout)
- proxy_request_buffering off + proxy_buffering off: stream upload
  bytes to immich as they arrive instead of buffering the whole file,
  keeping the TCP connection active and avoiding idle-socket timeouts
- proxy_connect_timeout 600s: explicit upstream connect timeout
2026-05-30 17:18:05 +02:00
Clément Désiles 314fa715fd fix(nginx): prevent cert leak on IPv6 / unknown SNI
Two issues caused TLS to break on photos.carabosse.cloud over IPv6
(GrapheneOS + Immich app via Orange 5G NAT64):

1. Per-service vhosts only listened on IPv4 (listen 443 ssl). On IPv6,
   nginx fell back to the first vhost loaded alphabetically and served
   its certificate, breaking hostname verification on every other vhost.

2. /etc/letsencrypt/{live,archive} were 0700 root:root after certbot
   created them, so the nginx worker (user http on Arch) could not read
   the chained intermediates and served the leaf-only chain.

Changes:
- Add catch-all 00-default.conf default_server on :80 and :443 (v4+v6)
  with a self-signed cert and 'return 444'. ACME challenges still
  answered on :80.
- Add IPv6 listeners ([::]:80 and [::]:443 ssl) to immich, gitea, ntfy,
  uptime_kuma vhosts and to the temporary ACME provisioning vhost.
- Apply 0755 on /etc/letsencrypt/live and /etc/letsencrypt/archive on
  every run, not only at initial cert provisioning.
2026-05-30 17:06:10 +02:00
Clément Désiles 80026fac0b fix: pin ansible.posix >=2.2.0 to silence _text deprecation warning 2026-05-30 17:05:58 +02:00
39 changed files with 768 additions and 74 deletions
+4 -11
View File
@@ -1,11 +1,4 @@
inventory/*
!inventory/hosts.example
!inventory/host_vars/
inventory/host_vars/*
!inventory/host_vars/example.yml
inventory_data/
playbook.yml
playbooks/*
!playbooks/example.yml
!playbooks/bootstrap.yml
TODO.md
/inventory
/inventory_data
/playbooks
/roadmap
+1
View File
@@ -2,6 +2,7 @@
collections:
- name: ansible.netcommon
- name: ansible.posix
version: ">=2.2.0"
- name: community.general
- name: community.postgresql
- name: containers.podman
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ gitea_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ gitea_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
+7
View File
@@ -13,6 +13,13 @@ Both passwords must be set in your inventory (min 12 characters):
- `immich_postgres_password` - PostgreSQL database password
- `immich_valkey_password` - Valkey/Redis password
## External Libraries
Mount host paths read-only into the server container via `immich_external_libraries`,
then add the in-container `mount_path` in the Immich UI
(Administration → External Libraries). The `{{ ansible_user }}` running the rootless
pod must have read access on the host path.
## Troubleshooting
### Valkey ACL Issues
+9
View File
@@ -5,6 +5,15 @@ immich_version: release
# Storage location (@see https://docs.immich.app/install/environment-variables/)
immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload"
# External libraries (read-only host paths exposed to the server container)
# Use the in-container `mount_path` when registering the library in the Immich UI.
# Example:
# immich_external_libraries:
# - name: clement-photos
# host_path: /mnt/andromeda/clement-photos
# mount_path: /mnt/external/clement-photos
immich_external_libraries: []
# PostgreSQL configuration (REQUIRED password - must be set explicitly)
immich_postgres_db_name: immich
immich_postgres_user: immich
+11
View File
@@ -45,6 +45,11 @@ spec:
readOnly: true
- name: immich-data
mountPath: /data
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
mountPath: {{ lib.mount_path }}
readOnly: true
{% endfor %}
livenessProbe:
httpGet:
path: /api/server/ping
@@ -83,6 +88,12 @@ spec:
hostPath:
path: {{ immich_upload_location }}
type: Directory
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
hostPath:
path: {{ lib.host_path }}
type: Directory
{% endfor %}
- name: model-cache
persistentVolumeClaim:
claimName: immich-model-cache
+15 -1
View File
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ immich_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,8 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ immich_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
@@ -38,6 +41,12 @@ server {
client_max_body_size 50000M;
# Timeouts for slow mobile uploads (client <-> nginx leg)
client_body_timeout 600s;
client_header_timeout 600s;
send_timeout 600s;
keepalive_timeout 600s;
location / {
proxy_pass http://127.0.0.1:{{ immich_port }};
proxy_set_header Host $http_host;
@@ -50,7 +59,12 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for large file uploads
# Stream uploads directly to backend instead of buffering full body on disk
proxy_request_buffering off;
proxy_buffering off;
# Timeouts for large file uploads (nginx <-> immich leg)
proxy_connect_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
+20
View File
@@ -0,0 +1,20 @@
# Metabase
Business intelligence and analytics. Defaults: [`defaults/main.yml`](defaults/main.yml).
## Requirements
- `podman` role
- `postgres` role
- `nginx` role (optional, for public access)
## Usage
Set in inventory:
```yaml
metabase_postgres_password: "strongpassword"
metabase_postgres_host: "{{ podman_gw_gateway }}"
metabase_nginx_enabled: true
metabase_nginx_hostname: metabase.example.com
```
+16
View File
@@ -0,0 +1,16 @@
---
metabase_version: latest
metabase_image: metabase/metabase
metabase_port: 3000
metabase_postgres_db_name: metabase
metabase_postgres_user: metabase
# metabase_postgres_password: "" # Intentionally undefined - role will fail if not set
# metabase_postgres_host: "" # Must be set in inventory (e.g. "{{ podman_gw_gateway }}")
metabase_postgres_port: 5432
metabase_timezone: UTC
metabase_nginx_enabled: false
metabase_nginx_hostname: metabase.nas.local
+20
View File
@@ -0,0 +1,20 @@
---
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart Metabase
ansible.builtin.systemd:
name: metabase.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+4
View File
@@ -0,0 +1,4 @@
---
dependencies:
- role: podman
- role: postgres
+130
View File
@@ -0,0 +1,130 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- metabase_postgres_password is defined
- metabase_postgres_password | length >= 12
fail_msg: |
metabase_postgres_password is required (min 12 chars).
See roles/metabase/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create PostgreSQL database for Metabase
community.postgresql.postgresql_db:
name: "{{ metabase_postgres_db_name }}"
owner: "{{ metabase_postgres_user }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL user for Metabase
community.postgresql.postgresql_user:
name: "{{ metabase_postgres_user }}"
password: "{{ metabase_postgres_password }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant all privileges on database to Metabase user
community.postgresql.postgresql_privs:
login_db: "{{ metabase_postgres_db_name }}"
roles: "{{ metabase_postgres_user }}"
type: database
privs: ALL
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Ensure Metabase user has no superuser privileges
community.postgresql.postgresql_user:
name: "{{ metabase_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Metabase project directory
ansible.builtin.file:
path: "{{ podman_projects_dir }}/metabase"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Pull Metabase container image
ansible.builtin.command: "podman pull {{ metabase_image }}:{{ metabase_version }}"
register: pull_result
changed_when: pull_result.stdout is search('Writing manifest')
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Metabase
ansible.builtin.template:
src: metabase.yaml.j2
dest: "{{ podman_projects_dir }}/metabase/metabase.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart Metabase
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Metabase
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Deploy systemd service for Metabase (user scope)
ansible.builtin.template:
src: metabase.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/metabase.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
- name: Enable and start Metabase service (user scope)
ansible.builtin.systemd:
name: metabase.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Metabase
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ metabase_nginx_hostname }}"
when: metabase_nginx_enabled
- name: Deploy nginx vhost configuration for Metabase
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/metabase.conf"
owner: root
group: root
mode: "0644"
when: metabase_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for Metabase
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/metabase.conf"
state: absent
when: not metabase_nginx_enabled
notify: Reload nginx
@@ -0,0 +1,15 @@
[Unit]
Description=Metabase BI Server
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/metabase
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} metabase.yaml
ExecStop=/usr/bin/podman kube down metabase.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+42
View File
@@ -0,0 +1,42 @@
apiVersion: v1
kind: Pod
metadata:
name: metabase
spec:
containers:
- name: server
image: {{ metabase_image }}:{{ metabase_version }}
ports:
- containerPort: 3000
hostPort: {{ metabase_port }}
env:
- name: MB_DB_TYPE
value: postgres
- name: MB_DB_DBNAME
value: "{{ metabase_postgres_db_name }}"
- name: MB_DB_PORT
value: "{{ metabase_postgres_port }}"
- name: MB_DB_USER
value: "{{ metabase_postgres_user }}"
- name: MB_DB_PASS
value: "{{ metabase_postgres_password }}"
- name: MB_DB_HOST
value: "{{ metabase_postgres_host }}"
- name: JAVA_TIMEZONE
value: "{{ metabase_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 90
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
volumes:
- name: localtime
hostPath: { path: /etc/localtime, type: File }
@@ -0,0 +1,49 @@
# Metabase vhost
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
listen [::]:80;
server_name {{ metabase_nginx_hostname }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ metabase_nginx_hostname }};
ssl_certificate /etc/letsencrypt/live/{{ metabase_nginx_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ metabase_nginx_hostname }}/privkey.pem;
ssl_protocols {{ nginx_ssl_protocols | default('TLSv1.3') }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend | default('journald') == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_metabase;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_metabase;
{% else %}
access_log /var/log/nginx/{{ metabase_nginx_hostname }}_access.log main;
error_log /var/log/nginx/{{ metabase_nginx_hostname }}_error.log;
{% endif %}
location / {
proxy_pass http://127.0.0.1:{{ metabase_port }};
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
+4
View File
@@ -16,3 +16,7 @@ nfs_port: 2049
nfs_server_firewall_allowed_sources:
- 127.0.0.0/8
# OS-dependent package name
nfs_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}
+1 -1
View File
@@ -1,7 +1,7 @@
---
- name: Install nfs-server
ansible.builtin.package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}"
name: "{{ nfs_package_name }}"
state: present
- name: Configure nfs configuration
+16
View File
@@ -11,6 +11,22 @@ Installs and configures Nginx as a reverse proxy for web applications with modul
- SSL/TLS configuration
- **Native ACME/Let's Encrypt support** (Nginx 1.25.0+)
- **Transparent proxy forwarding** (HTTP/HTTPS to other hosts)
- **Catch-all `default_server`** that rejects unknown SNI/Host with `444`
## Catch-all default_server
A `00-default.conf` vhost is deployed and marked `default_server` on both
ports 80 and 443. It uses a self-signed cert (`/etc/nginx/ssl/default.crt`)
and returns `444` (close connection) for any request whose SNI/Host does
not match an explicit vhost. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`)
are still answered on port 80 so Certbot keeps working for new hostnames.
Without this, clients hitting the server IP directly (or doing HTTP/2
connection coalescing across vhosts sharing the same IP) would receive the
certificate of the first vhost loaded alphabetically, leaking that
hostname and breaking TLS verification on other vhosts.
Disable with `nginx_default_server_enabled: false`.
## Service Integration Pattern
+6
View File
@@ -17,6 +17,12 @@ nginx_client_max_body_size: 100M
# SSL configuration (volontarily omit TLSv1.2 here)
nginx_ssl_protocols: TLSv1.3
# Catch-all default_server (rejects unknown SNI / Host with 444).
# Prevents leaking the first-loaded vhost's cert to unrelated requests.
nginx_default_server_enabled: true
nginx_default_ssl_cert: /etc/nginx/ssl/default.crt
nginx_default_ssl_key: /etc/nginx/ssl/default.key
# Logging configuration
# Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal)
nginx_log_backend: journald
+10
View File
@@ -29,6 +29,16 @@
path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
register: certbot_cert_file
- name: Ensure letsencrypt directories are traversable by nginx
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
when: certbot_cert_file.stat.exists
- name: Provision certificate for {{ certbot_hostname }}
when: not certbot_cert_file.stat.exists
block:
+43
View File
@@ -78,6 +78,49 @@
group: root
mode: "0755"
- name: Configure catch-all default_server
when: nginx_default_server_enabled
block:
- name: Ensure nginx ssl directory exists
ansible.builtin.file:
path: "{{ nginx_default_ssl_cert | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Generate self-signed cert for default_server
ansible.builtin.command:
cmd: >-
openssl req -x509 -nodes -newkey rsa:2048
-keyout {{ nginx_default_ssl_key }}
-out {{ nginx_default_ssl_cert }}
-days 3650 -subj "/CN=default"
creates: "{{ nginx_default_ssl_cert }}"
- name: Restrict permissions on default_server key
ansible.builtin.file:
path: "{{ nginx_default_ssl_key }}"
owner: root
group: root
mode: "0600"
- name: Deploy default_server vhost
ansible.builtin.template:
src: default-server.conf.j2
dest: "{{ nginx_conf_dir }}/00-default.conf"
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Remove default_server vhost when disabled
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/00-default.conf"
state: absent
when: not nginx_default_server_enabled
notify: Reload nginx
- name: Ensure Certbot webroot directory exists
ansible.builtin.file:
path: /var/www/certbot
@@ -0,0 +1,35 @@
# Catch-all default_server vhosts
# Managed by Ansible - DO NOT EDIT MANUALLY
#
# Purpose: reject any request whose Host/SNI does not match an explicit
# server_name. Without this, the first vhost loaded alphabetically would
# leak its certificate to unrelated SNI requests (e.g. clients doing
# HTTP/2 connection coalescing or hitting the IP directly).
#
# `return 444` closes the connection without sending an HTTP response.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Keep ACME HTTP-01 challenges working for any hostname
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 444;
}
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate {{ nginx_default_ssl_cert }};
ssl_certificate_key {{ nginx_default_ssl_key }};
return 444;
}
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ certbot_hostname }};
location /.well-known/acme-challenge/ {
+2
View File
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ ntfy_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ ntfy_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
+72
View File
@@ -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 <username>` 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`
+64
View File
@@ -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 <username>` 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') }}
+6
View File
@@ -0,0 +1,6 @@
---
- name: Restart samba
ansible.builtin.systemd:
name: "{{ samba_service_name }}"
state: restarted
daemon_reload: true
+87
View File
@@ -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('<unnamed>') }}"
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
+48
View File
@@ -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 %}
+6
View File
@@ -0,0 +1,6 @@
---
# OS-dependent package names
tooling_dig_package: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('bind', 'dnsutils') }}
tooling_netcat_package: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('openbsd-netcat', 'netcat-openbsd') }}
+15
View File
@@ -0,0 +1,15 @@
---
- name: Install command-line tooling
ansible.builtin.package:
name:
- usbutils
- htop
- bottom
- wget
- ethtool
- iperf3
- vim
- nano
- "{{ tooling_dig_package }}"
- "{{ tooling_netcat_package }}"
state: present
-60
View File
@@ -1,60 +0,0 @@
---
- name: Install usbutils
package:
name: usbutils
state: present
changed_when: false
- name: Install htop
package:
name: htop
state: present
changed_when: false
- name: Install bottom
package:
name: bottom
state: present
changed_when: false
- name: Install wget
package:
name: wget
state: present
changed_when: false
- name: Install dig utility
package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('bind', 'dnsutils') }}"
state: present
changed_when: false
- name: Install ethtool
package:
name: ethtool
state: present
changed_when: false
- name: Install iperf3
package:
name: iperf3
state: present
changed_when: false
- name: Install vim
package:
name: vim
state: present
changed_when: false
- name: Install nano
package:
name: nano
state: present
changed_when: false
- name: Install netcat
package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('openbsd-netcat', 'netcat-openbsd') }}"
state: present
changed_when: false
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ uptime_kuma_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ uptime_kuma_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
+4
View File
@@ -15,3 +15,7 @@ wireguard_config_base_path: /etc/wireguard
# endpoint: host:port # optional: peer's public endpoint
# persistent_keepalive: 25 # optional: keepalive interval (seconds)
wireguard_tunnels: []
# OS-dependent package name
wireguard_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('wireguard-tools', 'wireguard') }}
+1 -1
View File
@@ -11,7 +11,7 @@
- name: Install wireguard
ansible.builtin.package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('wireguard-tools', 'wireguard') }}"
name: "{{ wireguard_package_name }}"
state: present
# Use systemd-resolved for DNS management (modern approach on all distributions)