Compare commits

..

20 Commits

Author SHA1 Message Date
Clément Désiles 21f47196cf feat(net_config): support IPForward and IPMasquerade in network config 2026-06-19 23:55:02 +02:00
Clément Désiles e4ad6888b6 Introduce vault pass in the doc to push a good practice 2026-06-19 23:52:19 +02:00
Clément Désiles 4c57d28b4d Add new fdroid role to host custom apks 2026-06-19 23:50:56 +02:00
Clément Désiles 045c0b9bec refactor(zfs): inline dataset ownership, add absent cleanup
Drop separate dataset-ownership.yml task file. Use
extra_zfs_properties.mountpoint directly instead of zfs get.
Add rmdir cleanup for absent dataset mountpoints.
2026-06-19 23:46:33 +02:00
Clément Désiles 13b8aae769 Add support for NUT (EATON inverter) 2026-06-13 09:37:49 +02:00
Clément Désiles 25621a101c Merge branch 'main' of github.com:cdesiles/ansible-playbooks 2026-06-12 22:34:24 +02:00
Clément Désiles 0726e417d2 feat: add syncthing support 2026-06-03 23:28:26 +02:00
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
Clément Désiles c9e2ff930c feat(net_config): safer ufw restart on NAT/forwarding changes
- Replace 'ufw disable && ufw --force enable' single-shot handler with a
  block that dry-runs the ruleset, disables, re-enables, then verifies
  ufw is active. No '&&' short-circuit, so failures are loud instead of
  leaving the host firewall-less.
- Rename handler to 'Restart ufw (ip-forwarding settings changed)' to
  reflect that this is a full restart (required to pick up
  /etc/default/ufw and /etc/ufw/before.rules changes per ufw(8)).
- Add NAT/masquerade tasks: enable ipv4 forwarding, set
  DEFAULT_FORWARD_POLICY=ACCEPT, and write a per-interface *nat block
  in /etc/ufw/before.rules.
- Declare requires_ansible >=2.15 in meta/runtime.yml (handler uses
  block:, supported since 2.12; 2.15 is a safe modern floor).
- README: document Ansible version requirement, port reservation
  rules, and Immich pgvector Q&A.
2026-05-29 22:24:16 +02:00
Clément Désiles 36d6baaecb fix: missing task in wg 2026-05-29 21:54:25 +02:00
Clément Désiles 5f2c82d296 fix: use ansible_facts['getent_passwd'] to silence INJECT_FACTS_AS_VARS deprecation 2026-05-29 21:54:03 +02:00
Clément Désiles dbc7ca203a fix: minor taks name typo 2026-05-29 21:50:39 +02:00
84 changed files with 2112 additions and 110 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 -1
View File
@@ -239,7 +239,7 @@ boilerplate.
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory
ansible.builtin.file:
+32 -1
View File
@@ -34,8 +34,24 @@ This is a good playground to learn and I encourage you to adapt these roles to y
| static-web | Static website hosting |
| vpn | WireGuard server |
## Port Reservation Rules
Reserved ports that **must not** be used as role defaults:
| Port(s) | Protocol | Reserved for |
| --- | --- | --- |
| 80 | tcp | Nginx |
| 443 | tcp | Nginx |
| 3000-3009 | tcp | Testing |
| 4430 | tcp | Testing |
| 8080 | tcp | Testing |
When adding a new role, pick a default port outside these ranges.
## Requirements
Ansible `>=2.15`
Base tools:
```sh
@@ -61,7 +77,8 @@ If you have a password on your ssh key `--ask-pass` is recommended, `--ask-becom
```sh
ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-pass \
--ask-become-pass
--ask-become-pass \
--ask-vault-pass
```
You can also call you ssh agent to unlock your key prior to simplify your calls:
@@ -110,3 +127,17 @@ Linting:
ansible-lint
npx prettier --write .
```
## Q&A
### Immich crash loop: `PostgresError: must be owner of extension vector`
Immich tries to self-update the `pgvector` extension at startup, but its database user is intentionally `NOSUPERUSER`, so the `ALTER EXTENSION vector UPDATE` call fails and the microservices worker exits with code 1.
Fix it on the running host by updating the extension as the `postgres` superuser:
```sh
sudo -u postgres psql -d immich -c 'ALTER EXTENSION vector UPDATE;'
```
The Immich role also runs this automatically on subsequent playbook runs, so re-deployments after a pgvector package upgrade do not require manual intervention.
+2
View File
@@ -0,0 +1,2 @@
---
requires_ansible: ">=2.15"
+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
+71
View File
@@ -0,0 +1,71 @@
# fdroid - F-Droid Custom APK Repository
Deploys an [F-Droid](https://f-droid.org/) repository server using [austozi/fdroidserver](https://github.com/austozi/docker-fdroidserver) to host custom APKs for family devices.
## Configuration
### Required Variables
Set in inventory or vault:
```yaml
fdroid_keystore_password: "your-secure-password-here" # Min 12 chars
```
### Optional Variables
See [defaults/main.yml](defaults/main.yml) for all configuration options.
Key settings:
```yaml
fdroid_version: "26.2.1"
fdroid_port: 8070
fdroid_repo_url: "https://apk.jokester.fr/repo"
fdroid_repo_name: "F-Droid Repository"
fdroid_repo_description: "Custom APK repository"
fdroid_update_interval: "12h"
# Nginx reverse proxy
fdroid_nginx_enabled: false
fdroid_nginx_hostname: apk.nas.local
```
## Usage
### Adding APKs
```bash
scp my-app.apk jokester@andromeda:/opt/podman/fdroid/data/repo/
```
The container automatically re-runs `fdroid update` every `fdroid_update_interval` (default: 12h) to regenerate the signed index.
To trigger an immediate update:
```bash
ssh jokester@andromeda "podman exec fdroid-server fdroid update -c"
```
### F-Droid Client Setup
On family phones, open F-Droid and add a new repository:
- **Repository URL:** `https://apk.jokester.fr/repo`
- Accept the fingerprint on first connection
### Keystore Backup
The signing keystore at `{{ podman_projects_dir }}/fdroid/data/keystore.p12` is critical. If lost, all clients must re-add the repository. Back it up.
## Architecture
- **Container**: `austozi/fdroidserver` (Apache + fdroidserver + Android build-tools)
- **Storage**: Persistent data directory for keystore, config, metadata, and APKs
- **Networking**: Localhost binding, nginx reverse proxy for HTTPS
- **Index updates**: Automatic on configurable interval
## Dependencies
- podman
- nginx (if `fdroid_nginx_enabled: true`)
+29
View File
@@ -0,0 +1,29 @@
---
# F-Droid repository version (austozi/fdroidserver image tag)
fdroid_version: "26.2.1"
# Container image
fdroid_image: austozi/fdroidserver
# Host port mapping
fdroid_port: 8070
# Data directory (keystore, config, metadata, repo APKs)
fdroid_data_dir: "{{ podman_projects_dir }}/fdroid/data"
# Repository metadata
fdroid_repo_url: "https://apk.mysite.fr/repo"
fdroid_repo_name: "F-Droid Repository"
fdroid_repo_description: "Custom APK repository"
fdroid_repo_icon: "fdroid.svg"
fdroid_repo_icon_url: "https://f-droid.org/assets/fdroid-logo-text_S0MUfk_FsnAYL7n2MQye-34IoSNm6QM6xYjDnMqkufo=.svg"
# How often the container re-runs 'fdroid update' to re-sign the index
fdroid_update_interval: "24h"
# Keystore password for signing the repository index
# fdroid_keystore_password: "" # Intentionally undefined - role will fail if not set
# Nginx reverse proxy configuration
fdroid_nginx_enabled: false
fdroid_nginx_hostname: apk.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 fdroid
ansible.builtin.systemd:
name: fdroid.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+3
View File
@@ -0,0 +1,3 @@
---
dependencies:
- role: podman
+179
View File
@@ -0,0 +1,179 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- fdroid_keystore_password is defined
- fdroid_keystore_password | length >= 12
fail_msg: |
fdroid_keystore_password is required (min 12 chars).
See roles/fdroid/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create fdroid project directory
ansible.builtin.file:
path: "{{ podman_projects_dir | default('/opt/podman') }}/fdroid"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create fdroid data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
loop:
- "{{ fdroid_data_dir }}"
- "{{ fdroid_data_dir }}/repo"
- "{{ fdroid_data_dir }}/metadata"
- name: Create fdroid repo icons directory
ansible.builtin.file:
path: "{{ fdroid_data_dir }}/repo/icons"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Download fdroid repository icon
ansible.builtin.get_url:
url: "{{ fdroid_repo_icon_url }}"
dest: "{{ fdroid_data_dir }}/repo/icons/{{ fdroid_repo_icon }}"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
- name: Deploy fdroid repository configuration
ansible.builtin.template:
src: config.yml.j2
dest: "{{ fdroid_data_dir }}/config.yml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0600"
notify: Restart fdroid
- name: Pull fdroid container image
ansible.builtin.command: "podman pull {{ fdroid_image }}:{{ fdroid_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for fdroid
ansible.builtin.template:
src: fdroid.yaml.j2
dest: "{{ podman_projects_dir | default('/opt/podman') }}/fdroid/fdroid.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart fdroid
- 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 fdroid
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create systemd service for fdroid (user scope)
ansible.builtin.template:
src: fdroid.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/fdroid.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Check if lingering is enabled for {{ ansible_user }}
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ ansible_user }}"
register: linger_file
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
changed_when: true
when:
- ansible_user != 'root'
- not linger_file.stat.exists
- name: Check if keystore already exists
ansible.builtin.stat:
path: "{{ fdroid_data_dir }}/keystore.p12"
register: fdroid_keystore
- name: Initialize fdroid repository (generate keystore and first index)
ansible.builtin.command:
argv:
- podman
- run
- --rm
- -v
- "{{ fdroid_data_dir }}:/fdroid"
- -e
- "FDROID_REPO_URL={{ fdroid_repo_url }}"
- -e
- "FDROID_REPO_NAME={{ fdroid_repo_name }}"
- -e
- "FDROID_REPO_DESCRIPTION={{ fdroid_repo_description }}"
- -e
- "FDROID_REPO_ICON={{ fdroid_repo_icon }}"
- "{{ fdroid_image }}:{{ fdroid_version }}"
- "fdroid update -c --create-key"
when: not fdroid_keystore.stat.exists
register: fdroid_init
changed_when: fdroid_init.rc == 0
become: false
become_user: "{{ ansible_user }}"
- name: Flush handlers before starting fdroid
ansible.builtin.meta: flush_handlers
- name: Enable and start fdroid service (user scope)
ansible.builtin.systemd:
name: fdroid.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Wait for fdroid to be ready
ansible.builtin.wait_for:
port: "{{ fdroid_port }}"
host: 127.0.0.1
timeout: 60
- name: Provision TLS certificate for fdroid
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ fdroid_nginx_hostname }}"
when: fdroid_nginx_enabled
- name: Deploy nginx vhost configuration for fdroid
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/fdroid.conf"
owner: root
group: root
mode: "0644"
when: fdroid_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for fdroid
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/fdroid.conf"
state: absent
when: not fdroid_nginx_enabled
notify: Reload nginx
+14
View File
@@ -0,0 +1,14 @@
# F-Droid repository configuration
# Managed by Ansible - DO NOT EDIT MANUALLY
repo_url: "{{ fdroid_repo_url }}"
repo_name: "{{ fdroid_repo_name }}"
repo_description: "{{ fdroid_repo_description }}"
repo_icon: "{{ fdroid_repo_icon }}"
# Keystore configuration (auto-generated on first 'fdroid update -c --create-key')
repo_keyalias: fdroid-repo-key
keystore: keystore.p12
keystorepass: "{{ fdroid_keystore_password }}"
keypass: "{{ fdroid_keystore_password }}"
keydname: "CN={{ fdroid_nginx_hostname }}"
@@ -0,0 +1,56 @@
# F-Droid repository vhost with Let's Encrypt (Certbot)
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
listen [::]:80;
server_name {{ fdroid_nginx_hostname }};
# Certbot webroot for ACME challenges
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ fdroid_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/{{ fdroid_nginx_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ fdroid_nginx_hostname }}/privkey.pem;
# SSL configuration
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_fdroid;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_fdroid;
{% else %}
access_log /var/log/nginx/{{ fdroid_nginx_hostname }}_access.log main;
error_log /var/log/nginx/{{ fdroid_nginx_hostname }}_error.log;
{% endif %}
# Allow large APK uploads if ever needed
client_max_body_size 200M;
# Redirect root to /repo for F-Droid client compatibility
location = / {
return 302 /repo;
}
location / {
proxy_pass http://127.0.0.1:{{ fdroid_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;
}
}
+1 -1
View File
@@ -102,7 +102,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Gitea
ansible.builtin.file:
@@ -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
+1 -1
View File
@@ -124,7 +124,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Immich
ansible.builtin.file:
+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;
}
}
+23
View File
@@ -0,0 +1,23 @@
---
# UFW must be fully restarted (disable + enable) — not just reloaded — to pick
# up changes in /etc/default/ufw (DEFAULT_FORWARD_POLICY) and the *nat block
# in /etc/ufw/before.rules. See ufw(8) "RULE SYNTAX" → IP forwarding.
- name: Restart ufw (ip-forwarding settings changed)
block:
- name: Validate ufw ruleset before restart (dry-run)
ansible.builtin.command: ufw --dry-run reload
changed_when: false
- name: Disable ufw
ansible.builtin.command: ufw disable
changed_when: true
- name: Enable ufw
ansible.builtin.command: ufw --force enable
changed_when: true
- name: Verify ufw is active after restart
ansible.builtin.command: ufw status
register: ufw_status_after
changed_when: false
failed_when: "'Status: active' not in ufw_status_after.stdout"
+38
View File
@@ -32,3 +32,41 @@
ansible.builtin.set_fact:
network_reload_required: true
when: netdev_result is changed or network_result is changed
## Routing & NAT (when interface has forward + masquerade enabled)
- name: Enable IPv4 forwarding
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
sysctl_set: true
reload: true
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
- name: Set UFW default forward policy to ACCEPT
ansible.builtin.lineinfile:
path: /etc/default/ufw
regexp: "^DEFAULT_FORWARD_POLICY="
line: 'DEFAULT_FORWARD_POLICY="ACCEPT"'
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
notify: Restart ufw (ip-forwarding settings changed)
- name: Configure NAT masquerade in UFW before.rules for {{ interface.name }}
ansible.builtin.blockinfile:
path: /etc/ufw/before.rules
insertbefore: "^\\*filter"
marker: "# {mark} ANSIBLE MANAGED - NAT {{ interface.name }}"
block: |
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s {{ interface.ipv4.address | ansible.utils.ipaddr('network/prefix') }} -o {{ interface.ipv4.nat_out_interface }} -j MASQUERADE
COMMIT
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
- interface.ipv4.nat_out_interface is defined
notify: Restart ufw (ip-forwarding settings changed)
@@ -14,6 +14,12 @@ RouteMetric={{ interface.ipv4.metric }}
{% if interface.type is defined and interface.type == 'bridge' %}
ConfigureWithoutCarrier=yes
{% endif %}
{% if interface.ipv4.forward | default(false) %}
IPForward=ipv4
{% endif %}
{% if interface.ipv4.masquerade | default(false) %}
IPMasquerade=ipv4
{% endif %}
{% if interface.ipv4.nameservers is defined %}
{% for dns in interface.ipv4.nameservers %}
DNS={{ dns }}
+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/ {
+1 -1
View File
@@ -60,7 +60,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for ntfy
ansible.builtin.file:
+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)
+88
View File
@@ -0,0 +1,88 @@
# nut — Network UPS Tools
Monitors a UPS over USB (or serial/network), notifies via ntfy on power events
and gracefully shuts the host down on low battery.
## Supported distributions
- Arch Linux
- Debian/Ubuntu
## What it does
- Installs `nut` and configures it in **standalone** mode (single host, no
network slaves).
- Configures the `usbhid-ups` driver against the UPS defined in `nut_ups_name`
(default: EATON Ellipse 1600, vendorid `0463`).
- Binds `upsd` to `127.0.0.1:3493` only — no LAN exposure.
- Runs `upsmon` as master, which:
- calls `SHUTDOWNCMD` (`systemctl poweroff`) on `LOWBATT`,
- dispatches every event to a `NOTIFYCMD` wrapper that POSTs to ntfy with
severity, tags and a host-aware title.
## Configuration
Variables — see [defaults/main.yml](defaults/main.yml).
Required (role asserts at start):
```yaml
nut_monitor_password: "<min 12 chars>" # local upsd user used by upsmon + exporter
nut_ntfy_topic: "ups-<host>"
```
Optional but commonly tweaked:
```yaml
nut_ups_name: eaton
nut_ups_description: "EATON Ellipse 1600"
nut_ups_vendorid: "0463"
nut_ntfy_server: https://ntfy.jokester.fr
nut_ntfy_token: "tk_..." # publish token for nut_ntfy_topic
```
## Operations
### Check UPS status
```bash
upsc {{ nut_ups_name }}@localhost
```
### List configured UPSes
```bash
upsc -l
```
### Test the NOTIFYCMD pipeline without unplugging
```bash
sudo -u nut NOTIFYTYPE=ONBATT /usr/local/bin/ups-notify "Simulated ONBATT for ntfy plumbing test"
```
### Simulate a full power loss (DANGEROUS — actually powers off)
```bash
sudo upsmon -c fsd
```
### Logs
```bash
journalctl -u nut-monitor -u nut-server -u 'nut-driver@*' -f
```
## Security
- `upsd` binds to `127.0.0.1` only.
- `upsd.users` mode `0640` owned by `root:nut`.
- No anonymous read access — exporter and upsmon both authenticate as
`nut_monitor_user`.
- udev rules shipped by the `nut` package grant USB device access to the `nut`
group only.
## Companion role
See [`nut_exporter`](../nut_exporter/README.md) to expose Prometheus metrics
based on the same upsd instance.
+63
View File
@@ -0,0 +1,63 @@
---
# NUT (Network UPS Tools) configuration
# See: https://networkupstools.org/docs/man/upsmon.conf.html
# UPS definition
# --------------
# Logical name of the UPS as referenced everywhere (ups.conf section, upsmon
# MONITOR line, nut_exporter ?ups= query parameter).
nut_ups_name: eaton
# Human-readable description (shown in upsc output).
nut_ups_description: "EATON Ellipse 1600"
# Driver to use. usbhid-ups covers all USB HID-compliant UPSes (EATON, APC,
# CyberPower, etc.). See: https://networkupstools.org/stable-hcl.html
nut_ups_driver: usbhid-ups
# USB vendorid filter (EATON = 0463). Helps disambiguate if multiple USB HID
# devices are present. Leave empty to auto-detect.
nut_ups_vendorid: "0463"
# Driver polling interval in seconds. Some Eaton/MGE units lock up if polled too
# aggressively (the default is 2). 10-15s gives the microcontroller breathing room.
nut_ups_pollinterval: 15
# Number of connection attempts before the driver gives up. If the USB chip
# freezes, the driver will try to reopen the port up to this many times.
nut_ups_maxretry: 3
# upsd server
# -----------
# Bind addresses for upsd. Keep localhost-only unless you want to monitor from
# other hosts (in which case add the wireguard IP and adjust firewall).
nut_upsd_listen:
- { addr: "127.0.0.1", port: 3493 }
# Local monitor user used by upsmon and nut_exporter. Password must be set.
nut_monitor_user: monitor
# nut_monitor_password: "" # Intentionally undefined - role will fail if not set
# upsmon (shutdown manager + NOTIFYCMD dispatcher)
# ------------------------------------------------
# Battery charge percentage below which an early shutdown is triggered, even if
# the UPS has not yet asserted LOWBATT. Set to 0 to rely solely on LOWBATT.
nut_upsmon_minsupplies: 1
nut_upsmon_pollfreq: 5 # seconds between polls when on line power
nut_upsmon_pollfreqalert: 5 # seconds between polls when on battery
nut_upsmon_deadtime: 15 # seconds before declaring a UPS dead
nut_upsmon_hostsync: 15 # seconds to wait for slaves before shutting down
nut_upsmon_finaldelay: 5 # seconds between SHUTDOWN notification and poweroff
# Command run on the host once the master decides it is time to power off.
# systemctl poweroff is sufficient for a single-host standalone setup.
nut_upsmon_shutdown_cmd: "/usr/bin/systemctl poweroff"
# ntfy notifications
# ------------------
# Topic to publish UPS events to. Should be a dedicated topic for power events.
# nut_ntfy_topic: "" # Intentionally undefined - role will fail if not set
nut_ntfy_server: https://ntfy.jokester.fr
# nut_ntfy_token: "" # Intentionally undefined - unauthenticated if not set
# Path of the deployed NOTIFYCMD wrapper.
nut_notify_script_path: /usr/local/bin/ups-notify
+15
View File
@@ -0,0 +1,15 @@
---
- name: Restart NUT driver enumerator
ansible.builtin.systemd:
name: nut-driver-enumerator.service
state: restarted
- name: Restart NUT server
ansible.builtin.systemd:
name: nut-server.service
state: restarted
- name: Restart NUT monitor
ansible.builtin.systemd:
name: nut-monitor.service
state: restarted
+2
View File
@@ -0,0 +1,2 @@
---
dependencies: []
+97
View File
@@ -0,0 +1,97 @@
---
- name: Validate required configuration
ansible.builtin.assert:
that:
- nut_monitor_password is defined
- nut_monitor_password | length >= 12
- nut_ntfy_topic is defined
- nut_ntfy_topic | length > 0
fail_msg: |
nut_monitor_password (>=12 chars) and nut_ntfy_topic are required.
See roles/nut/defaults/main.yml for configuration.
- name: Load OS-specific variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['os_family'] }}.yml"
- debian.yml
- name: Install NUT
ansible.builtin.package:
name: "{{ nut_package }}"
state: present
- name: Ensure NUT config directory exists
ansible.builtin.file:
path: "{{ nut_config_dir }}"
state: directory
owner: root
group: "{{ nut_group }}"
mode: "0750"
- name: Set NUT to standalone mode
ansible.builtin.copy:
dest: "{{ nut_config_dir }}/nut.conf"
content: |
# Managed by Ansible - DO NOT EDIT MANUALLY
MODE=standalone
owner: root
group: "{{ nut_group }}"
mode: "0640"
notify:
- Restart NUT driver enumerator
- Restart NUT server
- Restart NUT monitor
- name: Deploy ups.conf
ansible.builtin.template:
src: ups.conf.j2
dest: "{{ nut_config_dir }}/ups.conf"
owner: root
group: "{{ nut_group }}"
mode: "0640"
notify:
- Restart NUT driver enumerator
- Restart NUT server
- name: Deploy upsd.conf
ansible.builtin.template:
src: upsd.conf.j2
dest: "{{ nut_config_dir }}/upsd.conf"
owner: root
group: "{{ nut_group }}"
mode: "0640"
notify: Restart NUT server
- name: Deploy upsd.users
ansible.builtin.template:
src: upsd.users.j2
dest: "{{ nut_config_dir }}/upsd.users"
owner: root
group: "{{ nut_group }}"
mode: "0640"
notify: Restart NUT server
- name: Deploy ntfy NOTIFYCMD script
ansible.builtin.template:
src: ups-notify.sh.j2
dest: "{{ nut_notify_script_path }}"
owner: root
group: root
mode: "0755"
- name: Deploy upsmon.conf
ansible.builtin.template:
src: upsmon.conf.j2
dest: "{{ nut_config_dir }}/upsmon.conf"
owner: root
group: "{{ nut_group }}"
mode: "0640"
notify: Restart NUT monitor
- name: Enable and start NUT services
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop: "{{ nut_services }}"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Managed by Ansible - DO NOT EDIT MANUALLY
#
# Wrapper invoked by upsmon as NOTIFYCMD.
# upsmon passes the rendered NOTIFYMSG as $1 and sets NOTIFYTYPE in the env.
# See: https://networkupstools.org/docs/man/upsmon.conf.html
set -euo pipefail
NTFY_SERVER="{{ nut_ntfy_server }}"
NTFY_TOPIC="{{ nut_ntfy_topic }}"
{% if nut_ntfy_token is defined %}
NTFY_TOKEN="{{ nut_ntfy_token }}"
{% else %}
NTFY_TOKEN=""
{% endif %}
MESSAGE="${1:-UPS event}"
EVENT="${NOTIFYTYPE:-UNKNOWN}"
HOST="$(uname -n)"
case "$EVENT" in
ONBATT)
TITLE="UPS on battery — $HOST"
PRIORITY="urgent"
TAGS="warning,electric_plug"
;;
LOWBATT)
TITLE="UPS low battery — $HOST"
PRIORITY="urgent"
TAGS="rotating_light,battery"
;;
FSD|SHUTDOWN)
TITLE="UPS forced shutdown — $HOST"
PRIORITY="max"
TAGS="skull"
;;
ONLINE)
TITLE="UPS back on line power — $HOST"
PRIORITY="default"
TAGS="white_check_mark,zap"
;;
COMMBAD|NOCOMM)
TITLE="UPS communication lost — $HOST"
PRIORITY="high"
TAGS="warning,satellite"
;;
COMMOK)
TITLE="UPS communication restored — $HOST"
PRIORITY="default"
TAGS="white_check_mark"
;;
REPLBATT)
TITLE="UPS battery needs replacement — $HOST"
PRIORITY="high"
TAGS="battery,wrench"
;;
*)
TITLE="UPS event ($EVENT) — $HOST"
PRIORITY="default"
TAGS="information_source"
;;
esac
auth_args=()
if [[ -n "$NTFY_TOKEN" ]]; then
auth_args=(-H "Authorization: Bearer $NTFY_TOKEN")
fi
# --max-time is important: upsmon will hang on poweroff if curl blocks.
curl -fsS --max-time 10 \
"${auth_args[@]}" \
-H "Title: $TITLE" \
-H "Priority: $PRIORITY" \
-H "Tags: $TAGS" \
-d "$MESSAGE" \
"${NTFY_SERVER%/}/${NTFY_TOPIC}" >/dev/null || \
logger -t ups-notify "Failed to publish ntfy notification for $EVENT"
+12
View File
@@ -0,0 +1,12 @@
# Managed by Ansible - DO NOT EDIT MANUALLY
# See: https://networkupstools.org/docs/man/ups.conf.html
[{{ nut_ups_name }}]
driver = {{ nut_ups_driver }}
port = auto
desc = "{{ nut_ups_description }}"
pollinterval = {{ nut_ups_pollinterval }}
maxretry = {{ nut_ups_maxretry }}
{% if nut_ups_vendorid %}
vendorid = {{ nut_ups_vendorid }}
{% endif %}
+6
View File
@@ -0,0 +1,6 @@
# Managed by Ansible - DO NOT EDIT MANUALLY
# See: https://networkupstools.org/docs/man/upsd.conf.html
{% for listen in nut_upsd_listen %}
LISTEN {{ listen.addr }} {{ listen.port }}
{% endfor %}
+6
View File
@@ -0,0 +1,6 @@
# Managed by Ansible - DO NOT EDIT MANUALLY
# See: https://networkupstools.org/docs/man/upsd.users.html
[{{ nut_monitor_user }}]
password = {{ nut_monitor_password }}
upsmon master
+37
View File
@@ -0,0 +1,37 @@
# Managed by Ansible - DO NOT EDIT MANUALLY
# See: https://networkupstools.org/docs/man/upsmon.conf.html
MONITOR {{ nut_ups_name }}@localhost {{ nut_upsmon_minsupplies }} {{ nut_monitor_user }} {{ nut_monitor_password }} master
MINSUPPLIES {{ nut_upsmon_minsupplies }}
SHUTDOWNCMD "{{ nut_upsmon_shutdown_cmd }}"
NOTIFYCMD "{{ nut_notify_script_path }}"
POLLFREQ {{ nut_upsmon_pollfreq }}
POLLFREQALERT {{ nut_upsmon_pollfreqalert }}
DEADTIME {{ nut_upsmon_deadtime }}
HOSTSYNC {{ nut_upsmon_hostsync }}
FINALDELAY {{ nut_upsmon_finaldelay }}
# Default notification messages (overridable per event).
NOTIFYMSG ONLINE "UPS %s is back on line power"
NOTIFYMSG ONBATT "UPS %s is on battery (mains lost)"
NOTIFYMSG LOWBATT "UPS %s battery is low — shutdown imminent"
NOTIFYMSG FSD "UPS %s forced shutdown in progress"
NOTIFYMSG COMMOK "Communications with UPS %s restored"
NOTIFYMSG COMMBAD "Communications with UPS %s lost"
NOTIFYMSG SHUTDOWN "System is shutting down due to UPS %s"
NOTIFYMSG REPLBATT "UPS %s battery needs replacement"
NOTIFYMSG NOCOMM "UPS %s is unavailable"
# Route events through SYSLOG and the NOTIFYCMD wrapper. NUT also supports
# WALL (broadcast to logged-in users) but it's noisy and not useful here.
NOTIFYFLAG ONLINE SYSLOG+EXEC
NOTIFYFLAG ONBATT SYSLOG+EXEC
NOTIFYFLAG LOWBATT SYSLOG+EXEC
NOTIFYFLAG FSD SYSLOG+EXEC
NOTIFYFLAG COMMOK SYSLOG+EXEC
NOTIFYFLAG COMMBAD SYSLOG+EXEC
NOTIFYFLAG SHUTDOWN SYSLOG+EXEC
NOTIFYFLAG REPLBATT SYSLOG+EXEC
NOTIFYFLAG NOCOMM SYSLOG+EXEC
+9
View File
@@ -0,0 +1,9 @@
---
nut_package: nut
nut_config_dir: /etc/nut
nut_user: nut
nut_group: nut
nut_services:
- nut-driver-enumerator.service
- nut-server.service
- nut-monitor.service
+9
View File
@@ -0,0 +1,9 @@
---
nut_package: nut
nut_config_dir: /etc/nut
nut_user: nut
nut_group: nut
nut_services:
- nut-driver-enumerator.service
- nut-server.service
- nut-monitor.service
+60
View File
@@ -0,0 +1,60 @@
# nut_exporter — Prometheus exporter for NUT
Scrapes a local `upsd` and exposes UPS metrics for Prometheus.
## Supported distributions
- Arch Linux (AUR package `prometheus-nut-exporter`, installed via `paru`)
Debian/Ubuntu is not packaged upstream — add it on demand.
## Configuration
See [defaults/main.yml](defaults/main.yml).
Required:
```yaml
nut_exporter_nut_password: "<same as nut_monitor_password>"
```
Optional:
```yaml
nut_exporter_listen_address: "127.0.0.1:9199"
nut_exporter_nut_server: "127.0.0.1:3493"
nut_exporter_nut_user: monitor
```
## Pairing with Prometheus
Typical scrape config (target uses the multi-target pattern: the exporter
queries a remote upsd specified in the URL parameters):
```yaml
prometheus_scrape_configs:
- job_name: 'nut'
metrics_path: /nut
static_configs:
- targets: ['eaton@localhost'] # ups@host syntax
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: 127.0.0.1:9199
```
## Operations
```bash
systemctl status prometheus-nut-exporter
curl -s 'http://127.0.0.1:9199/nut?target=localhost&ups=eaton' | head
journalctl -u prometheus-nut-exporter -f
```
## Dependencies
Requires the [`nut`](../nut/README.md) role (or any other running upsd) on the
same host.
+13
View File
@@ -0,0 +1,13 @@
---
# Prometheus NUT exporter configuration
# Address the exporter listens on.
nut_exporter_listen_address: "127.0.0.1:9199"
# upsd server to connect to (kept local — exporter sits next to upsd).
nut_exporter_nut_server: "127.0.0.1:3493"
# Credentials used to log into upsd. These should match the upsd user defined
# by the nut role (nut_monitor_user / nut_monitor_password).
nut_exporter_nut_user: "{{ nut_monitor_user | default('monitor') }}"
# nut_exporter_nut_password: "" # Inherits nut_monitor_password by default
+9
View File
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart nut_exporter
ansible.builtin.systemd:
name: "{{ nut_exporter_service }}"
state: restarted
+2
View File
@@ -0,0 +1,2 @@
---
dependencies: []
+46
View File
@@ -0,0 +1,46 @@
---
- name: Validate required configuration
ansible.builtin.assert:
that:
- nut_exporter_nut_password is defined
- nut_exporter_nut_password | length >= 12
fail_msg: |
nut_exporter_nut_password (>=12 chars) is required.
Usually set to the same value as nut_monitor_password.
- name: Load OS-specific variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['os_family'] }}.yml"
- name: Install prometheus-nut-exporter (AUR via paru)
ansible.builtin.command: "paru -S --noconfirm --needed {{ nut_exporter_package }}"
register: nut_exporter_install
changed_when: "'there is nothing to do' not in nut_exporter_install.stdout | lower"
when: ansible_facts['os_family'] == 'Archlinux'
- name: Ensure systemd override directory exists
ansible.builtin.file:
path: "{{ nut_exporter_override_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy systemd override (listen address + upsd credentials)
ansible.builtin.template:
src: override.conf.j2
dest: "{{ nut_exporter_override_dir }}/override.conf"
owner: root
group: root
mode: "0640"
notify:
- Reload systemd
- Restart nut_exporter
- name: Enable and start nut_exporter
ansible.builtin.systemd:
name: "{{ nut_exporter_service }}"
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,10 @@
# Managed by Ansible - DO NOT EDIT MANUALLY
# Override for prometheus-nut-exporter to inject listen address and upsd
# credentials. The exporter reads NUT_EXPORTER_* env vars at startup.
[Service]
Environment="HTTP_LISTEN_ADDRESS={{ nut_exporter_listen_address }}"
Environment="NUT_EXPORTER_SERVER={{ nut_exporter_nut_server.split(':')[0] }}"
Environment="NUT_EXPORTER_PORT={{ nut_exporter_nut_server.split(':')[1] }}"
Environment="NUT_EXPORTER_USERNAME={{ nut_exporter_nut_user }}"
Environment="NUT_EXPORTER_PASSWORD={{ nut_exporter_nut_password }}"
+6
View File
@@ -0,0 +1,6 @@
---
nut_exporter_package: prometheus-nut-exporter
nut_exporter_service: prometheus-nut-exporter.service
nut_exporter_user: nut-exporter
nut_exporter_group: nut-exporter
nut_exporter_override_dir: /etc/systemd/system/prometheus-nut-exporter.service.d
+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 %}
+48
View File
@@ -0,0 +1,48 @@
# syncthing
Installs and configures [Syncthing](https://syncthing.net/) as a system service.
Runs as a dedicated `syncthing` system user via `syncthing@syncthing.service`.
Supports Arch Linux and Debian-based distributions.
## Required variables
Set these in `inventory/host_vars/<host>.yml`:
```yaml
syncthing_gui_user: admin
syncthing_gui_password: "{{ vault_syncthing_gui_password }}"
```
`syncthing_gui_password` must be at least 12 characters. Set the actual value
in your vault file and reference it via `vault_syncthing_gui_password`.
Syncthing will bcrypt-hash the password on first start.
## Optional variables
See `defaults/main.yml` for the full list. Key options:
| Variable | Default | Description |
|-----------------------------|--------------------------------|------------------------------------|
| `syncthing_user` | `syncthing` | OS user to run syncthing as |
| `syncthing_home` | `/var/lib/syncthing` | Home directory for the system user |
| `syncthing_config_dir` | `{{ syncthing_home }}/.config/syncthing` | Config directory |
| `syncthing_gui_bind` | `0.0.0.0` | GUI listen address |
| `syncthing_gui_port` | `8384` | GUI listen port |
| `syncthing_port` | `22000` | Sync protocol port (TCP) |
| `syncthing_allowed_networks` | `[]` | UFW rules for GUI and sync ports |
## Notes
- `config.xml` is written only on first run — the task is skipped on subsequent
runs if the file already exists. Syncthing manages the file after that (device
ID, folder config, hashed password). Re-running the playbook is safe.
- Folder and device pairing must be done via the Syncthing web UI or REST API
after the service is running.
- The GUI binds to `0.0.0.0` by default — use `syncthing_allowed_networks` to
restrict access via UFW to specific LAN/VPN ranges.
## Debian notes
The `syncthing` package in some Debian versions may be outdated. Consider adding
the [official APT repository](https://apt.syncthing.net/) before applying this role.
+27
View File
@@ -0,0 +1,27 @@
---
# System user to run syncthing as
syncthing_user: syncthing
syncthing_group: syncthing
syncthing_home: /var/lib/syncthing
# Config directory (syncthing reads and writes this at runtime)
syncthing_config_dir: "{{ syncthing_home }}/.config/syncthing"
# GUI credentials (REQUIRED - must be set explicitly)
# syncthing_gui_user: "" # Intentionally undefined - role will fail if not set
# syncthing_gui_password: "" # Intentionally undefined - role will fail if not set
# GUI listen address and port
syncthing_gui_bind: "0.0.0.0"
syncthing_gui_port: 8384
# Sync protocol port (TCP)
syncthing_port: 22000
# Package and service names
syncthing_package: syncthing
syncthing_service: "syncthing@{{ syncthing_user }}"
# Firewall rules - list of allowed source ranges for GUI and sync ports
syncthing_allowed_networks:
- { src: "127.0.0.1/8", comment: "Localhost" }
+5
View File
@@ -0,0 +1,5 @@
---
- name: Restart syncthing
ansible.builtin.systemd:
name: "{{ syncthing_service }}"
state: restarted
+81
View File
@@ -0,0 +1,81 @@
---
- name: Validate required variables are set
ansible.builtin.assert:
that:
- syncthing_gui_user is defined
- syncthing_gui_user | length >= 1
- syncthing_gui_password is defined
- syncthing_gui_password | length >= 12
fail_msg: |
syncthing_gui_user and syncthing_gui_password are required.
syncthing_gui_password must be at least 12 characters.
See roles/syncthing/defaults/main.yml for configuration instructions.
- name: Install syncthing
ansible.builtin.package:
name: "{{ syncthing_package }}"
state: present
- name: Create syncthing system group
ansible.builtin.group:
name: "{{ syncthing_group }}"
system: true
state: present
- name: Create syncthing system user
ansible.builtin.user:
name: "{{ syncthing_user }}"
group: "{{ syncthing_group }}"
home: "{{ syncthing_home }}"
shell: /sbin/nologin
system: true
create_home: true
state: present
- name: Create syncthing config directory
ansible.builtin.file:
path: "{{ syncthing_config_dir }}"
state: directory
owner: "{{ syncthing_user }}"
group: "{{ syncthing_group }}"
mode: "0700"
- name: Check if syncthing config already exists
ansible.builtin.stat:
path: "{{ syncthing_config_dir }}/config.xml"
register: syncthing_config_stat
- name: Deploy initial syncthing config (skipped if already exists)
ansible.builtin.template:
src: config.xml.j2
dest: "{{ syncthing_config_dir }}/config.xml"
owner: "{{ syncthing_user }}"
group: "{{ syncthing_group }}"
mode: "0600"
when: not syncthing_config_stat.stat.exists
notify: Restart syncthing
- name: Allow syncthing GUI and sync traffic through firewall
community.general.ufw:
rule: allow
port: "{{ item.1.port }}"
proto: tcp
from: "{{ item.0.src }}"
direction: in
comment: "{{ item.0.comment }}"
loop: "{{ syncthing_allowed_networks | product(syncthing_ufw_ports) | list }}"
vars:
syncthing_ufw_ports:
- { port: "{{ syncthing_gui_port }}" }
- { port: "{{ syncthing_port }}" }
when: syncthing_allowed_networks | length > 0
retries: 5
delay: 2
register: ufw_result
until: ufw_result is succeeded
- name: Enable and start syncthing service
ansible.builtin.systemd:
name: "{{ syncthing_service }}"
enabled: true
state: started
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Managed by Ansible - initial bootstrap only, DO NOT EDIT MANUALLY -->
<!-- Syncthing will manage this file after first start -->
<configuration version="37">
<gui enabled="true" tls="false" debugging="false">
<address>{{ syncthing_gui_bind }}:{{ syncthing_gui_port }}</address>
<user>{{ syncthing_gui_user }}</user>
<!-- Plaintext password; syncthing will bcrypt-hash it on first start -->
<password>{{ syncthing_gui_password }}</password>
<theme>default</theme>
<insecureSkipHostcheck>false</insecureSkipHostcheck>
</gui>
<options>
<startBrowser>false</startBrowser>
<crashReportingEnabled>false</crashReportingEnabled>
<!-- Opt out of anonymous usage reporting -->
<urAccepted>-1</urAccepted>
<!-- Disable automatic upgrades (managed by package manager) -->
<autoUpgradeIntervalH>0</autoUpgradeIntervalH>
</options>
</configuration>
+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
+1 -1
View File
@@ -38,7 +38,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for uptime-kuma
ansible.builtin.file:
@@ -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)
+91
View File
@@ -0,0 +1,91 @@
---
- name: "Validate required fields for tunnel {{ _tunnel.interface }}"
ansible.builtin.assert:
that:
- _tunnel.interface is defined
- _tunnel.interface | length > 0
- _tunnel.address is defined
- _tunnel.address | length > 0
fail_msg: |
Tunnel is missing required fields: 'interface' and 'address' are mandatory.
See roles/wireguard/defaults/main.yml for configuration instructions.
success_msg: "Tunnel {{ _tunnel.interface }} validation passed"
- name: "Check if private key exists for {{ _tunnel.interface }}"
ansible.builtin.stat:
path: "{{ wireguard_config_base_path }}/{{ _tunnel.interface }}.privatekey"
register: _tunnel_pkey_file
- name: "Generate wireguard keys for {{ _tunnel.interface }} if not present"
ansible.builtin.shell: >
wg genkey |
tee {{ wireguard_config_base_path }}/{{ _tunnel.interface }}.privatekey |
wg pubkey > {{ wireguard_config_base_path }}/{{ _tunnel.interface }}.publickey
when: not _tunnel_pkey_file.stat.exists
- name: "Retrieve wireguard private key for {{ _tunnel.interface }}"
ansible.builtin.slurp:
src: "{{ wireguard_config_base_path }}/{{ _tunnel.interface }}.privatekey"
register: _tunnel_private_key_b64
- name: "Set wireguard private key fact for {{ _tunnel.interface }}"
ansible.builtin.set_fact:
_tunnel_private_key: "{{ _tunnel_private_key_b64['content'] | b64decode }}"
- name: "Resolve effective DNS for {{ _tunnel.interface }}"
ansible.builtin.set_fact:
_tunnel_effective_dns: "{{ (_tunnel.dns | default('')) if (unbound_custom_lan_records is not defined) else '' }}"
- name: "Install wireguard config for {{ _tunnel.interface }}"
ansible.builtin.template:
src: wireguard.conf.j2
dest: "{{ wireguard_config_base_path }}/{{ _tunnel.interface }}.conf"
owner: root
group: root
mode: "0600"
- name: "Create systemd override directory for wg-quick@{{ _tunnel.interface }}"
ansible.builtin.file:
path: "/etc/systemd/system/wg-quick@{{ _tunnel.interface }}.service.d"
state: directory
mode: "0755"
- name: "Deploy systemd override for network dependency for {{ _tunnel.interface }}"
ansible.builtin.template:
src: systemd-override.conf.j2
dest: "/etc/systemd/system/wg-quick@{{ _tunnel.interface }}.service.d/network-dependency.conf"
mode: "0644"
notify: Reload systemd
- name: "Enable IP forwarding for {{ _tunnel.interface }}"
ansible.builtin.copy:
dest: /etc/sysctl.d/99-wireguard.conf
content: |
net.ipv4.ip_forward = 1
owner: root
group: root
mode: "0644"
notify: Apply sysctl
when: _tunnel.server_mode | default(false)
- name: "Configure the firewall for {{ _tunnel.interface }}"
community.general.ufw:
rule: allow
port: "{{ _tunnel.port }}"
proto: udp
direction: in
comment: "Wireguard VPN ({{ _tunnel.interface }})"
retries: 5
delay: 2
register: _ufw_result
until: _ufw_result is succeeded
when:
- _tunnel.server_mode | default(false)
- _tunnel.port is defined
- name: "Start and enable wg-quick@{{ _tunnel.interface }}"
ansible.builtin.service:
name: "wg-quick@{{ _tunnel.interface }}"
state: started
enabled: true
daemon_reload: true
-24
View File
@@ -1,24 +0,0 @@
---
# due to Ansible limitations, we cannot loop over a block, so we loop over distinct task files
# @see https://stackoverflow.com/a/58911694
- name: Set ownership on dataset mountpoint
block:
- name: Get the mountpoint
ansible.builtin.command: "zfs get -H -o value mountpoint {{ dataset.name }}"
register: mountpoint
changed_when: false
- name: Fail if mountpoint is system directory
ansible.builtin.fail:
msg: "Mountpoint resolved to a system directory ({{ mountpoint.stdout }}), aborting to avoid changing ownership."
when:
- mountpoint.stdout | trim == ''
- mountpoint.stdout | trim is not match('^/$|^(/usr|/bin|/sbin|/etc|/var|/lib|/lib64)$')
- name: Set mountpoint ownership
ansible.builtin.file:
path: "{{ mountpoint.stdout }}"
owner: "{{ dataset.user | default(ansible_user) }}"
group: "{{ dataset.group | default(ansible_user) }}"
state: directory
recurse: false
+24 -4
View File
@@ -8,8 +8,28 @@
origin: "{{ item.origin | default(omit) }}"
with_items: "{{ zfs_datasets }}"
- name: Set dataset ownership
ansible.builtin.include_tasks: "./dataset-ownership.yml"
- name: Set dataset mountpoint ownership
ansible.builtin.file:
path: "{{ item.extra_zfs_properties.mountpoint }}"
owner: "{{ item.user | default(ansible_user) }}"
group: "{{ item.group | default(ansible_user) }}"
state: directory
recurse: false
loop: "{{ zfs_datasets }}"
loop_control:
loop_var: dataset
when:
- item.state | default('present') == 'present'
- item.extra_zfs_properties.mountpoint is defined
- item.extra_zfs_properties.mountpoint not in ['none', 'legacy']
- name: Remove leftover empty mountpoint for absent datasets
ansible.builtin.command: "rmdir {{ item.extra_zfs_properties.mountpoint }}"
register: rmdir_result
failed_when:
- rmdir_result.rc != 0
- "'No such file or directory' not in rmdir_result.stderr"
- "'Directory not empty' not in rmdir_result.stderr"
changed_when: rmdir_result.rc == 0
loop: "{{ zfs_datasets }}"
when:
- item.state | default('present') == 'absent'
- item.extra_zfs_properties.mountpoint is defined
+1 -1
View File
@@ -25,7 +25,7 @@
ansible.builtin.command:
cmd: "paru -S --noconfirm zfs-dkms zfs-utils"
- name: Restore SUDOERS password prompt after yay
- name: Restore SUDOERS password prompt after paru
no_log: true
ansible.builtin.lineinfile:
dest: /etc/sudoers