Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f3eebd422 | |||
| d976a9d701 | |||
| e74fffd5fc | |||
| 30dfb9ee8b | |||
| b0324cf3fe | |||
| a6ca97ca0e | |||
| b2a66099aa | |||
| 314fa715fd | |||
| 80026fac0b |
+4
-11
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
dependencies:
|
||||
- role: podman
|
||||
- role: postgres
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/ {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
@@ -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') }}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart samba
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ samba_service_name }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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') }}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user