Compare commits

...

44 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
Clément Désiles a8545fc501 fix(podman): use Type=notify + service-container so systemd sees pod crashes
The previous Type=oneshot + RemainAfterExit=true pattern made systemd
freeze pod units in 'active (exited)' as soon as 'podman play kube'
returned, so crash-looping containers were invisible to
'systemctl --user --failed' and Restart=on-failure never fired.

For every podman-pod role (immich, fdroid, ntfy, gitea, qfieldcloud,
unifi, matrix, uptime_kuma):

- switch units to Type=notify + NotifyAccess=all
- run 'podman kube play --service-container=true' so the unit's main
  PID stays alive as long as the pod
- use 'podman kube down' for ExecStop
- add TimeoutStartSec=180 to cover slow first-boot image pulls

Pod manifests: flip every container's restartPolicy from Always to
Never. systemd is now the single owner of the restart loop: container
exits -> pod dies -> service container dies -> unit fails ->
Restart=on-failure restarts everything cleanly. With Always, podman
retried internally and hid the failure from systemd.

CLAUDE.md updated to document the new canonical template and the
'restartPolicy: Never' requirement.
2026-05-29 21:49:13 +02:00
Clément Désiles 29d9f27052 fix: ntfy probe without curl available 2026-05-29 21:41:04 +02:00
Clément Désiles b04939d3d0 fix: use ansible_facts['kernel'] to avoid deprecation warning 2026-05-29 21:35:54 +02:00
Clément Désiles ff3133f8e7 feat: wireguard role allow multiple endpoints 2026-05-29 21:32:08 +02:00
Clément Désiles 4ae7721070 fix: secure pg + fix old way of sharing podman network 2026-05-29 21:31:07 +02:00
Clément Désiles ffeff6556b fix: restart dhcpd on nas restart 2026-05-29 21:29:14 +02:00
Clément Désiles 436fba0d39 Merge branch 'main' of github.com:cdesiles/ansible-playbooks 2026-05-29 21:28:41 +02:00
Clément Désiles 92deb854d2 fix: enhance tooling 2026-05-29 21:27:25 +02:00
Clément Désiles 05e7ee3956 fix: tls for static web 2026-05-29 21:27:00 +02:00
Clément Désiles aea450dc9d feat: nginx certbot 2026-05-29 21:26:17 +02:00
Clément Désiles 1d00432061 fix: podman integration 2026-05-29 21:24:58 +02:00
Clément Désiles 7904275754 nfs: minor tweak 2026-05-22 00:07:24 +02:00
Clément Désiles 305b8324db feat: sys autoupdate 2026-05-08 23:47:21 +02:00
Clément Désiles ea0771a5ac fix: update wireguard example 2026-05-05 22:59:46 +02:00
Clément Désiles 48e87f7cb1 zsh: enhance configuration with alacritty 2026-05-05 22:58:56 +02:00
Clément Désiles 4ac40b9898 fix: nginx defaults override on archlinux 2026-05-05 22:56:08 +02:00
Clément Désiles 488be1280c fix: dhcpd startup dependency 2026-05-05 22:54:56 +02:00
Clément Désiles de165f5e1c fix: review archlinux install details 2026-04-11 22:55:03 +02:00
Clément Désiles f9397ad38c feat: allow sshd to bind on multiple networks 2026-04-11 22:54:35 +02:00
Clément Désiles ac40c23d06 feat: more base tools 2026-04-11 22:53:19 +02:00
Clément Désiles 6fc7879648 fix: uptime-kuma reverse proxy config 2026-04-11 22:53:06 +02:00
Clément Désiles c4136ba5d2 fix: ntfy 2026-04-11 22:52:29 +02:00
Clément Désiles 4d60c6ea34 fix: zshrc to take p10k theme 2026-04-11 22:51:28 +02:00
Clément Désiles ae33184aa0 fix: zsh role add fzf support and p10k settings tweaks 2026-04-11 22:41:10 +02:00
135 changed files with 6721 additions and 668 deletions
+4 -11
View File
@@ -1,11 +1,4 @@
inventory/* /inventory
!inventory/hosts.example /inventory_data
!inventory/host_vars/ /playbooks
inventory/host_vars/* /roadmap
!inventory/host_vars/example.yml
inventory_data/
playbook.yml
playbooks/*
!playbooks/example.yml
!playbooks/bootstrap.yml
TODO.md
+357
View File
@@ -0,0 +1,357 @@
# Project Architecture & Patterns
## Limit documentation
- Only one README per role and a general project README.
- Default vars are already documented in ./roles/<role>/defaults/main.yml, no need to document them elsewhere. A link to this file from ./roles/<role>/README.md is sufficient.
## Target Audience
This repository targets **power users, developers, and homelab enthusiasts** who are comfortable with:
- Command-line interfaces and SSH
- Basic networking and Linux system administration
- Reading technical documentation and following references
- Ansible concepts (roles, playbooks, inventory)
**Documentation Philosophy:**
- Straight to the point, no hand-holding
- Minimal redundancy - references between documents preferred
- Assumes familiarity with underlying technologies
- Technical accuracy over verbosity
## Overview
This Ansible repository manages NAS/homelab infrastructure with a focus on:
- **Shared services pattern**: System-level PostgreSQL, Valkey, Nginx
- **Service isolation**: Separate databases and users per service
- **Security first**: Localhost-only access, minimal privileges, fail-fast validation
- **Modularity**: Independent service deployments
## Key Architectural Decisions
### 1. Shared Services Pattern
**Why:** Resource efficiency, easier maintenance, better isolation
**Implementation:**
- Single PostgreSQL instance serves all services
- Single Valkey instance serves all services
- Each service creates its own database/user (PostgreSQL)
- Each service gets its own ACL user (Valkey)
### 2. Multi-Layer Isolation
#### PostgreSQL Isolation
Each service:
- Gets its own database
- Gets its own user with minimal privileges
- Cannot access other services' data
**Security:**
- `NOSUPERUSER`: Cannot create other superusers
- `NOCREATEDB`: Cannot create databases
- `NOCREATEROLE`: Cannot create roles
- `priv: ALL` on own database only
#### Valkey/Redis Isolation
**Important:** Valkey ACL files do NOT support comments. The `users.acl` file must contain only ACL rules, one per line. All documentation should be in role README files, not in the ACL file itself.
Each service gets its own ACL user with:
- Unique credentials (username/password)
- Key pattern restrictions (can only access specific key prefixes)
- Command restrictions (deny dangerous commands like FLUSHDB, KEYS, CONFIG)
- Database number assignment (0-15) for additional logical separation
**Security (ACL-based):**
- Key patterns: `~immich_bull*` restricts access to matching keys only
- Command groups: `-@dangerous` denies FLUSHDB, FLUSHALL, KEYS, CONFIG, etc.
- Selective grants: `+@read +@write +@pubsub` only allows necessary operations
- Channel access: `&*` for pub/sub (job queues like BullMQ)
- Lua scripting: `+eval +evalsha` only when required (e.g., BullMQ)
**Defense-in-depth:**
1. ACL users with restricted permissions (primary)
2. Database number isolation (secondary)
3. Key pattern enforcement (tertiary)
### 3. Container-to-Host Communication
**Challenge:** Containers need to reach system PostgreSQL/Valkey
**Solution:**
- Rootless Podman with `pasta` networking and `--map-host-loopback={{ podman_gw_gateway }}`
- Inside containers, the host's loopback is reachable at `{{ podman_gw_gateway }}` (default `100.64.0.1`)
- Use `host.containers.internal` (resolves to the same address) or the literal gateway IP
- Avoids insecure `network_mode: host`
The default is configured in `/etc/containers/containers.conf` via the
`podman` role:
```ini
[network]
default_rootless_network_cmd = "pasta"
pasta_options = ["--map-host-loopback", "100.64.0.1"]
```
Note: `default_rootless_network_cmd` only affects `podman run`. `podman play
kube` defaults to a bridge network, which rootless users cannot create. Pod
services must pass the flag explicitly on the CLI:
```
ExecStart=/usr/bin/podman play kube --replace \
--network=pasta:--map-host-loopback={{ podman_gw_gateway }} myservice.yaml
```
### 4. Nginx Reverse Proxy Pattern
**Why:** Independent deployments, zero-downtime reloads
**Implementation:**
- Each service deploys its own vhost config to `{{ nginx_conf_dir }}/<service>.conf`
- Default `nginx_conf_dir: /etc/nginx/conf.d` (configurable in inventory)
- Services control exposure via `<service>_nginx_enabled` variable
- Nginx reloads gracefully when configs change
- **Always use `{{ nginx_conf_dir }}` variable, never hardcode paths**
### 5. OS Abstraction
**Why:** Support multiple distributions (Arch Linux, Debian/Ubuntu)
**Implementation:**
```
roles/postgres/vars/
├── archlinux.yml # Arch-specific (user: postgres, package: postgresql)
└── debian.yml # Debian-specific (user: postgres, package: postgresql)
```
Tasks load: `with_first_found: ["{{ ansible_facts['os_family'] }}.yml", "debian.yml"]`
## Creating a New Service Role
### 1. Directory Structure
```
roles/myservice/
├── defaults/main.yml # Variables
├── tasks/main.yml # Main tasks
├── handlers/main.yml # Handlers
├── templates/
│ ├── myservice.yaml.j2 # Kubernetes Pod spec (if containerized)
│ ├── myservice.service.j2 # systemd user unit (if containerized)
│ └── nginx-vhost.conf.j2 # If web-accessible
├── meta/main.yml # Dependencies
└── README.md # Documentation
```
### 2. Meta Dependencies
```yaml
dependencies:
- role: podman # If using containers
- role: postgres # If needs database
- role: redis # If needs cache
```
**Important:** Only include dependencies that are **always** required. Optional dependencies (like nginx for reverse proxy) should be added explicitly in playbooks, not in `meta/main.yml`.
### 3. Rootless Podman and User Systemd Services
**Architecture:** This is a single-user administrative server running rootless Podman. All containerized services:
- Run as `{{ ansible_user }}` (rootless Podman)
- Have files owned by `{{ ansible_user }}`
- Use systemd user services (not system services)
- Require lingering to start at boot without login
**Critical Implementation Details:**
1. **User Systemd Services Template:**
```jinja2
[Unit]
Description=My Service
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/myservice
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} myservice.yaml
ExecStop=/usr/bin/podman kube down myservice.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
```
**Why `podman kube play --service-container=true`:** All containerized
services in this repo use Kubernetes Pod manifests deployed via
`podman kube play`. The `--service-container=true` flag spawns an extra
long-lived container whose lifetime mirrors the pod, so the systemd unit's
main PID stays alive and emits sd_notify. This is what makes
`Type=notify`, `Restart=on-failure`, and `systemctl --user --failed` work
correctly — without it, `kube play` exits 0 immediately and systemd stays
in `active (exited)` forever, even while containers inside the pod
crash-loop. The explicit `--network=pasta:...` flag is required because
`podman kube play` defaults to a bridge network, which rootless users
cannot create (see section 3).
**Do not use** `Type=oneshot` + `RemainAfterExit=true` for podman pods.
That pattern silently hides crash loops from systemd.
**Pod manifest must use `restartPolicy: Never` on every container.**
systemd is the single owner of the restart loop: a container exit takes the
pod down, which takes the service container down, which fails the unit,
which triggers `Restart=on-failure`. With `restartPolicy: Always` or
`OnFailure`, podman retries internally and systemd never sees the failure.
**Future direction:** see `roadmap/2026-05-29-quadlet.md` for the planned migration to
native Podman Quadlet units (`.kube` files), which removes most of this
boilerplate.
**Critical differences from system services:**
- `WantedBy=default.target` (NOT `multi-user.target`)
- No `network-online.target` dependency (doesn't exist in user systemd)
- User services start after the system is up, so network dependencies are implicit
2. **Service File Placement:**
```yaml
- 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
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Deploy systemd service
ansible.builtin.template:
src: myservice.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/myservice.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
```
**Why getent + set_fact:** `ansible_env.HOME` is evaluated in the controller/initial context and may resolve to `/root` instead of the target user's home. Using `getent` to query `/etc/passwd` provides the correct home directory (field 4), and storing it in a fact makes subsequent references cleaner and more readable.
3. **Enable Lingering:**
```yaml
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
```
Lingering ensures the user's systemd instance starts at boot and persists when the user is not logged in.
4. **Service Management:**
```yaml
- name: Enable and start service (user scope)
ansible.builtin.command: "systemctl --user enable --now myservice.service"
become_user: "{{ ansible_user }}"
```
**Why `podman play kube`:** Kubernetes Pod manifests are portable and let multiple containers share a network namespace without defining a Compose network. The `--network=pasta:...` CLI flag overrides the default bridge (which rootless users cannot create) and inherits the same loopback mapping configured in `containers.conf`.
### 4. Password Validation Pattern (REQUIRED)
All roles requiring passwords **must** validate them at the start of tasks:
```yaml
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- myservice_password is defined
- myservice_password | length >= 12
fail_msg: |
myservice_password is required (min 12 chars).
See roles/myservice/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
```
**Why this pattern:**
- Prevents accidental deployment with missing/weak passwords
- Fails fast with clear error message
- Directs users to defaults/main.yml for setup instructions
- Keeps error messages concise (target audience: minimal tech knowledge)
**In defaults/main.yml, passwords should be undefined:**
```yaml
# myservice_password: "" # Intentionally undefined - role will fail if not set
```
**Never use "changeme" defaults** - always fail if password not explicitly set.
## Best Practices
### Ansible Usage
**`become: true` is redundant** - All playbooks are run with `--ask-become-pass`, so every task already runs with elevated privileges. Only use `become_user` to switch to a specific user (e.g., `become_user: postgres`).
### Security
1. **Passwords**: Always use Ansible Vault in production
```bash
ansible-vault encrypt_string 'password' --name 'myservice_db_password'
```
2. **Bind addresses**: PostgreSQL and Redis bind to `127.0.0.1` only
3. **Database users**: Minimal privileges (NOSUPERUSER, NOCREATEDB, NOCREATEROLE)
4. **Nginx**: Only expose services that need external access
### Variable Naming
- Role-specific: `<role>_variable_name`
- Generic (cross-role): Add to `.ansible-lint` skip list
### File Permissions
- Config files: `0644`
- Secrets: `0640` or `0600`
- Directories: `0755`
- Data directories: `0750`
### Idempotency
- Use `creates:` with command/shell
- Use `changed_when: false` for read-only operations
- Use appropriate `when:` conditions
### Handlers
- Use `notify` instead of direct state changes
- Keep in `handlers/main.yml`
- Common: `Reload nginx`, `Restart PostgreSQL`, `Reload systemd`
+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 | | static-web | Static website hosting |
| vpn | WireGuard server | | 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 ## Requirements
Ansible `>=2.15`
Base tools: Base tools:
```sh ```sh
@@ -61,7 +77,8 @@ If you have a password on your ssh key `--ask-pass` is recommended, `--ask-becom
```sh ```sh
ansible-playbook -i inventory/hosts.yml playbook.yml \ ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-pass \ --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: You can also call you ssh agent to unlock your key prior to simplify your calls:
@@ -110,3 +127,17 @@ Linting:
ansible-lint ansible-lint
npx prettier --write . 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.
@@ -14,10 +14,6 @@ network_interfaces:
- name: lan1 - name: lan1
type: ethernet type: ethernet
mac_address: 0a:3f:5b:1c:d2:e4 mac_address: 0a:3f:5b:1c:d2:e4
- name: podman-gw
type: bridge
ipv4:
address: "{{ podman_gw_gateway }}/10"
# Unbound DNS resolver configuration # Unbound DNS resolver configuration
# ---------------------------------- # ----------------------------------
@@ -114,17 +110,19 @@ zfs_datasets:
mountpoint: /mnt/omer/movies mountpoint: /mnt/omer/movies
state: present state: present
# Wireguard "client" VPN configuration # Wireguard VPN configuration
# ------------------------------------ # ----------------------------
wireguard_address: 192.168.20.4/27 wireguard_tunnels:
wireguard_peers: - interface: wg0
- name: "Marge server" address: 192.168.20.4/27
public_key: fB6zC8oWpQxN4yR2sT1uA7vJ9kH3mG5eD0cLlI8bV6aF2dP3eXwZ1qY4rU7tO9 dns: 192.168.20.1
allowed_ips: server_mode: false
- 192.168.20.1/32 peers:
endpoint: 192.168.1.56:51820 - name: "Marge server"
wireguard_dns: 192.168.20.1 public_key: fB6zC8oWpQxN4yR2sT1uA7vJ9kH3mG5eD0cLlI8bV6aF2dP3eXwZ1qY4rU7tO9
wireguard_server_mode: false allowed_ips:
- 192.168.20.1/32
endpoint: 192.168.1.56:51820
# NFS server configuration # NFS server configuration
# ------------------------ # ------------------------
@@ -149,24 +147,24 @@ nfs_bind_addresses:
# Podman configuration # Podman configuration
# -------------------- # --------------------
podman_gw_gateway: 100.64.0.1 # Address inside containers that maps to the host's loopback (via pasta
podman_gw_subnet: 100.64.0.0/10 # --map-host-loopback). Containers reach host services bound to 127.0.0.1
# by connecting to this address. Defined in roles/podman/defaults/main.yml.
# podman_gw_gateway: 100.64.0.1
# PostgreSQL configuration # PostgreSQL configuration
# ------------------------ # ------------------------
postgres_admin_password: "{{ vault_postgres_admin_password }}" postgres_admin_password: "{{ vault_postgres_admin_password }}"
postgres_bind: "127.0.0.1,{{ podman_gw_gateway }}" # Comma-separated for PostgreSQL postgres_bind: "127.0.0.1"
postgres_firewall_allowed_sources: postgres_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
- "{{ podman_gw_subnet }}"
# Valkey configuration # Valkey configuration
# -------------------- # --------------------
valkey_admin_password: "{{ vault_valkey_admin_password }}" valkey_admin_password: "{{ vault_valkey_admin_password }}"
valkey_bind: "127.0.0.1 {{ podman_gw_gateway }}" # Space-separated for Valkey valkey_bind: "127.0.0.1"
valkey_firewall_allowed_sources: valkey_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
- "{{ podman_gw_subnet }}"
# Valkey ACL users # Valkey ACL users
valkey_acl_users: valkey_acl_users:
+2
View File
@@ -0,0 +1,2 @@
---
requires_ansible: ">=2.15"
+1
View File
@@ -2,6 +2,7 @@
collections: collections:
- name: ansible.netcommon - name: ansible.netcommon
- name: ansible.posix - name: ansible.posix
version: ">=2.2.0"
- name: community.general - name: community.general
- name: community.postgresql - name: community.postgresql
- name: containers.podman - name: containers.podman
+4
View File
@@ -3,6 +3,10 @@
ansible.builtin.meta: end_play ansible.builtin.meta: end_play
when: ansible_facts['os_family'] != 'Archlinux' when: ansible_facts['os_family'] != 'Archlinux'
- name: Set hostname
ansible.builtin.hostname:
name: "{{ inventory_hostname }}"
- name: Archlinux base setup - name: Archlinux base setup
ansible.builtin.include_tasks: "{{ item }}" ansible.builtin.include_tasks: "{{ item }}"
loop: loop:
+1 -1
View File
@@ -68,7 +68,7 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
paru_url: "{{ item.browser_download_url }}" paru_url: "{{ item.browser_download_url }}"
loop: "{{ paru_release.json.assets }}" loop: "{{ paru_release.json.assets }}"
when: "'os_arch.tar.zst' in item.name" when: "(os_arch + '.tar.zst') in item.name"
- name: Download - name: Download
ansible.builtin.get_url: ansible.builtin.get_url:
+8 -2
View File
@@ -22,21 +22,27 @@
line: "%wheel ALL=(ALL) NOPASSWD: ALL" line: "%wheel ALL=(ALL) NOPASSWD: ALL"
validate: /usr/sbin/visudo -cf %s validate: /usr/sbin/visudo -cf %s
- name: Clean up stale yay sources dir
ansible.builtin.file:
path: "{{ yay_src_path }}"
state: absent
- name: Create yay sources dir - name: Create yay sources dir
ansible.builtin.file: ansible.builtin.file:
path: "{{ yay_src_path }}" path: "{{ yay_src_path }}"
state: directory state: directory
owner: "{{ ansible_user }}" owner: "{{ ansible_user }}"
mode: "0755"
- name: Clone git sources - name: Clone git sources
become: false become_user: "{{ ansible_user }}"
ansible.builtin.git: ansible.builtin.git:
repo: "{{ yay_git_repo }}" repo: "{{ yay_git_repo }}"
dest: "{{ yay_src_path }}" dest: "{{ yay_src_path }}"
# note: this only works because SUDOERS password prompt is disabled # note: this only works because SUDOERS password prompt is disabled
- name: Build and install - name: Build and install
become: false become_user: "{{ ansible_user }}"
ansible.builtin.command: ansible.builtin.command:
chdir: "{{ yay_src_path }}" chdir: "{{ yay_src_path }}"
cmd: "makepkg -si -f --noconfirm" cmd: "makepkg -si -f --noconfirm"
+2 -1
View File
@@ -2,7 +2,8 @@
[Unit] [Unit]
Description=IPv4 DHCP server on %I Description=IPv4 DHCP server on %I
After=sys-subsystem-net-devices-%i.device After=sys-subsystem-net-devices-%i.device network-online.target systemd-networkd-wait-online@%i.service
Wants=network-online.target systemd-networkd-wait-online@%i.service
BindsTo=sys-subsystem-net-devices-%i.device BindsTo=sys-subsystem-net-devices-%i.device
[Service] [Service]
+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 }}"
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=F-Droid Repository Server
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/fdroid
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} fdroid.yaml
ExecStop=/usr/bin/podman kube down fdroid.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+53
View File
@@ -0,0 +1,53 @@
---
apiVersion: v1
kind: Pod
metadata:
name: fdroid
labels:
app: fdroid
spec:
containers:
- name: server
image: {{ fdroid_image }}:{{ fdroid_version }}
ports:
- containerPort: 80
hostPort: {{ fdroid_port }}
env:
- name: TZ
value: "Europe/Paris"
- name: FDROID_REPO_URL
value: "{{ fdroid_repo_url }}"
- name: FDROID_REPO_NAME
value: "{{ fdroid_repo_name }}"
- name: FDROID_REPO_DESCRIPTION
value: "{{ fdroid_repo_description }}"
- name: FDROID_REPO_ICON
value: "{{ fdroid_repo_icon }}"
- name: FDROID_UPDATE_INTERVAL
value: "{{ fdroid_update_interval }}"
command: ["bash", "-c"]
args: ["apache2ctl -D FOREGROUND & fdroid update -c && while true; do sleep {{ fdroid_update_interval }} && fdroid update; done"]
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: fdroid-data
mountPath: /fdroid
- name: fdroid-repo
mountPath: /var/www/html/repo
readOnly: true
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: fdroid-data
hostPath:
path: {{ fdroid_data_dir }}
type: Directory
- name: fdroid-repo
hostPath:
path: {{ fdroid_data_dir }}/repo
type: Directory
@@ -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;
}
}
+7 -1
View File
@@ -102,7 +102,7 @@
- name: Set user home directory fact - name: Set user home directory fact
ansible.builtin.set_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 - name: Create systemd user directory for Gitea
ansible.builtin.file: ansible.builtin.file:
@@ -134,6 +134,12 @@
become: false become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Gitea
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ gitea_nginx_hostname }}"
when: gitea_nginx_enabled
- name: Deploy nginx vhost configuration for Gitea - name: Deploy nginx vhost configuration for Gitea
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Gitea Git Service Description=Gitea Git Service
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea
ExecStart=/usr/bin/podman play kube --replace gitea.yaml ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} gitea.yaml
ExecStop=/usr/bin/podman play kube --down gitea.yaml ExecStop=/usr/bin/podman kube down gitea.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+1 -1
View File
@@ -41,7 +41,7 @@ spec:
readOnly: true readOnly: true
- name: gitea-data - name: gitea-data
mountPath: /data mountPath: /data
restartPolicy: Always restartPolicy: Never
volumes: volumes:
- name: localtime - name: localtime
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ gitea_nginx_hostname }}; server_name {{ gitea_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
server_name {{ gitea_nginx_hostname }}; server_name {{ gitea_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # 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_postgres_password` - PostgreSQL database password
- `immich_valkey_password` - Valkey/Redis 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 ## Troubleshooting
### Valkey ACL Issues ### Valkey ACL Issues
+11 -2
View File
@@ -5,17 +5,26 @@ immich_version: release
# Storage location (@see https://docs.immich.app/install/environment-variables/) # Storage location (@see https://docs.immich.app/install/environment-variables/)
immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload" 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) # PostgreSQL configuration (REQUIRED password - must be set explicitly)
immich_postgres_db_name: immich immich_postgres_db_name: immich
immich_postgres_user: immich immich_postgres_user: immich
# immich_postgres_password: "" # Intentionally undefined - role will fail if not set # immich_postgres_password: "" # Intentionally undefined - role will fail if not set
# immich_postgres_host: "" # Must be set in inventory (e.g., podman_gw_gateway) # immich_postgres_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host postgres)
immich_postgres_port: 5432 immich_postgres_port: 5432
# Valkey configuration (REQUIRED password - must be set explicitly) # Valkey configuration (REQUIRED password - must be set explicitly)
immich_valkey_user: immich immich_valkey_user: immich
# immich_valkey_password: "" # Intentionally undefined - role will fail if not set # immich_valkey_password: "" # Intentionally undefined - role will fail if not set
# immich_valkey_host: "" # Must be set in inventory (e.g., podman_gw_gateway) # immich_valkey_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host valkey)
immich_valkey_port: 6379 immich_valkey_port: 6379
immich_valkey_db: 0 # Dedicated database number for isolation (0-15) immich_valkey_db: 0 # Dedicated database number for isolation (0-15)
+19 -1
View File
@@ -57,6 +57,18 @@
- earthdistance - earthdistance
- vector - vector
- name: Update PostgreSQL extensions to latest available version
community.postgresql.postgresql_query:
login_db: "{{ immich_postgres_db_name }}"
query: "ALTER EXTENSION {{ item }} UPDATE"
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
loop:
- cube
- earthdistance
- vector
changed_when: false
- name: Grant schema permissions to Immich user - name: Grant schema permissions to Immich user
community.postgresql.postgresql_privs: community.postgresql.postgresql_privs:
login_db: "{{ immich_postgres_db_name }}" login_db: "{{ immich_postgres_db_name }}"
@@ -112,7 +124,7 @@
- name: Set user home directory fact - name: Set user home directory fact
ansible.builtin.set_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 - name: Create systemd user directory for Immich
ansible.builtin.file: ansible.builtin.file:
@@ -144,6 +156,12 @@
become: false become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Immich
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ immich_nginx_hostname }}"
when: immich_nginx_enabled
- name: Deploy nginx vhost configuration for Immich - name: Deploy nginx vhost configuration for Immich
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Immich Media Server Description=Immich Media Server
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich
ExecStart=/usr/bin/podman play kube --replace immich.yaml ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} immich.yaml
ExecStop=/usr/bin/podman play kube --down immich.yaml ExecStop=/usr/bin/podman kube down immich.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+13 -5
View File
@@ -5,9 +5,6 @@ metadata:
name: immich name: immich
labels: labels:
app: immich app: immich
annotations:
io.podman.annotations.network.mode: bridge
io.podman.annotations.network.name: podman-gw
spec: spec:
containers: containers:
- name: server - name: server
@@ -48,6 +45,11 @@ spec:
readOnly: true readOnly: true
- name: immich-data - name: immich-data
mountPath: /data mountPath: /data
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
mountPath: {{ lib.mount_path }}
readOnly: true
{% endfor %}
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/server/ping path: /api/server/ping
@@ -56,7 +58,7 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
failureThreshold: 3 failureThreshold: 3
restartPolicy: Always restartPolicy: Never
- name: machine-learning - name: machine-learning
image: {{ immich_ml_image }}:{{ immich_version }} image: {{ immich_ml_image }}:{{ immich_version }}
@@ -75,7 +77,7 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
failureThreshold: 3 failureThreshold: 3
restartPolicy: Always restartPolicy: Never
volumes: volumes:
- name: localtime - name: localtime
@@ -86,6 +88,12 @@ spec:
hostPath: hostPath:
path: {{ immich_upload_location }} path: {{ immich_upload_location }}
type: Directory type: Directory
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
hostPath:
path: {{ lib.host_path }}
type: Directory
{% endfor %}
- name: model-cache - name: model-cache
persistentVolumeClaim: persistentVolumeClaim:
claimName: immich-model-cache claimName: immich-model-cache
+15 -1
View File
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ immich_nginx_hostname }}; server_name {{ immich_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,8 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ immich_nginx_hostname }}; server_name {{ immich_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # Let's Encrypt certificates (managed by Certbot)
@@ -38,6 +41,12 @@ server {
client_max_body_size 50000M; 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 / { location / {
proxy_pass http://127.0.0.1:{{ immich_port }}; proxy_pass http://127.0.0.1:{{ immich_port }};
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
@@ -50,7 +59,12 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "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_read_timeout 600s;
proxy_send_timeout 600s; proxy_send_timeout 600s;
} }
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Matrix Synapse + Element Web
Wants=network-online.target
After=network-online.target
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/matrix
ExecStart=/usr/bin/podman kube play --replace --service-container=true matrix.yaml
ExecStop=/usr/bin/podman kube down matrix.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+63
View File
@@ -0,0 +1,63 @@
---
apiVersion: v1
kind: Pod
metadata:
name: matrix
labels:
app: matrix
spec:
containers:
- name: synapse
image: {{ synapse_image }}:{{ synapse_version }}
ports:
- containerPort: 8008
hostPort: {{ synapse_port }}
{% if synapse_enable_federation %}
- containerPort: 8448
hostPort: {{ synapse_federation_port }}
{% endif %}
env:
- name: SYNAPSE_CONFIG_PATH
value: /data/homeserver.yaml
- name: TZ
value: "{{ matrix_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: synapse-data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
- name: element
image: {{ element_image }}:{{ element_version }}
ports:
- containerPort: 80
hostPort: 8080
volumeMounts:
- name: element-config
mountPath: /app/config.json
subPath: config.json
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: synapse-data
hostPath:
path: {{ synapse_data_dir }}
type: Directory
- name: element-config
hostPath:
path: {{ element_data_dir }}
type: Directory
+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: ansible.builtin.set_fact:
network_reload_required: true network_reload_required: true
when: netdev_result is changed or network_result is changed 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' %} {% if interface.type is defined and interface.type == 'bridge' %}
ConfigureWithoutCarrier=yes ConfigureWithoutCarrier=yes
{% endif %} {% 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 %} {% if interface.ipv4.nameservers is defined %}
{% for dns in interface.ipv4.nameservers %} {% for dns in interface.ipv4.nameservers %}
DNS={{ dns }} DNS={{ dns }}
+1 -1
View File
@@ -7,7 +7,7 @@
- name: Process ethernet interface persistence - name: Process ethernet interface persistence
when: interface.type is not defined or interface.type == 'ethernet' when: interface.type is not defined or interface.type == 'ethernet'
block: block:
- name: "Check interface rule for {{ interface.name }} ({{ interface.mac_address }})" - name: "Check interface rule for {{ interface.name }} ({{ interface.mac_address | default('N/A') }})"
ansible.builtin.set_fact: ansible.builtin.set_fact:
interface_original_name: "{{ ansible_facts.interfaces | select('in', ansible_facts) | map('extract', ansible_facts) | selectattr('pciid', 'defined') | selectattr('macaddress', 'equalto', interface.mac_address) | map(attribute='device') | first }}" interface_original_name: "{{ ansible_facts.interfaces | select('in', ansible_facts) | map('extract', ansible_facts) | selectattr('pciid', 'defined') | selectattr('macaddress', 'equalto', interface.mac_address) | map(attribute='device') | first }}"
+23
View File
@@ -20,6 +20,29 @@
interface: "{{ item }}" interface: "{{ item }}"
loop: "{{ hostvars[inventory_hostname].network_interfaces | default([]) }}" loop: "{{ hostvars[inventory_hostname].network_interfaces | default([]) }}"
- name: Remove stale podman-gw systemd-networkd configuration
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/network/10-podman-gw.netdev
- /etc/systemd/network/20-podman-gw.network
register: stale_podman_gw
- name: Mark networkd reload required after podman-gw cleanup
ansible.builtin.set_fact:
network_reload_required: true
when: stale_podman_gw is changed
- name: Tear down podman-gw bridge interface if present
ansible.builtin.command: ip link delete podman-gw
register: podman_gw_link_del
changed_when: podman_gw_link_del.rc == 0
failed_when:
- podman_gw_link_del.rc != 0
- "'Cannot find device' not in podman_gw_link_del.stderr"
- "'does not exist' not in podman_gw_link_del.stderr"
- name: Reload networkd and resolved - name: Reload networkd and resolved
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{ item }}" name: "{{ item }}"
+4
View File
@@ -16,3 +16,7 @@ nfs_port: 2049
nfs_server_firewall_allowed_sources: nfs_server_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
# OS-dependent package name
nfs_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}
+6 -1
View File
@@ -1,7 +1,7 @@
--- ---
- name: Install nfs-server - name: Install nfs-server
ansible.builtin.package: ansible.builtin.package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}" name: "{{ nfs_package_name }}"
state: present state: present
- name: Configure nfs configuration - name: Configure nfs configuration
@@ -28,6 +28,11 @@
state: started state: started
enabled: true enabled: true
- name: Mask nfs-server service to prevent conflicts with nfsv4-server
ansible.builtin.systemd:
name: nfs-server
masked: true
- name: Setup firewall rules for nfs on port - name: Setup firewall rules for nfs on port
community.general.ufw: community.general.ufw:
rule: allow rule: allow
+16
View File
@@ -11,6 +11,22 @@ Installs and configures Nginx as a reverse proxy for web applications with modul
- SSL/TLS configuration - SSL/TLS configuration
- **Native ACME/Let's Encrypt support** (Nginx 1.25.0+) - **Native ACME/Let's Encrypt support** (Nginx 1.25.0+)
- **Transparent proxy forwarding** (HTTP/HTTPS to other hosts) - **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 ## Service Integration Pattern
+6
View File
@@ -17,6 +17,12 @@ nginx_client_max_body_size: 100M
# SSL configuration (volontarily omit TLSv1.2 here) # SSL configuration (volontarily omit TLSv1.2 here)
nginx_ssl_protocols: TLSv1.3 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 # Logging configuration
# Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal) # Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal)
nginx_log_backend: journald nginx_log_backend: journald
+87
View File
@@ -0,0 +1,87 @@
---
# Provision a Let's Encrypt certificate for a hostname using the webroot method.
#
# Required variables:
# - certbot_hostname: the domain to provision (e.g. "apk.jokester.fr")
# - acme_email: Let's Encrypt account email (typically from host_vars)
#
# Usage from a service role:
# - name: Provision TLS certificate
# ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
# vars:
# certbot_hostname: "{{ myservice_nginx_hostname }}"
# when: myservice_nginx_enabled
- name: Validate certbot requirements
ansible.builtin.assert:
that:
- certbot_hostname is defined
- certbot_hostname | length > 0
- acme_email is defined
- acme_email | length > 0
fail_msg: |
certbot_hostname and acme_email are required for certificate provisioning.
Set acme_email in host_vars and pass certbot_hostname when including this task file.
success_msg: "Certbot requirements validated for {{ certbot_hostname }}"
- name: Check if certificate already exists for {{ certbot_hostname }}
ansible.builtin.stat:
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:
- name: Deploy temporary HTTP-only vhost for ACME challenge
ansible.builtin.template:
src: "{{ role_path }}/../nginx/templates/vhost-http-acme.conf.j2"
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
owner: root
group: root
mode: "0644"
- name: Reload nginx to activate temporary ACME vhost
ansible.builtin.systemd:
name: nginx
state: reloaded
- name: Request certificate from Let's Encrypt for {{ certbot_hostname }}
ansible.builtin.command:
cmd: >-
certbot certonly
--webroot
-w /var/www/certbot
-d {{ certbot_hostname }}
--email {{ acme_email }}
--agree-tos
--non-interactive
creates: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
- name: Fix letsencrypt directory permissions for nginx
ansible.builtin.file:
path: "{{ item }}"
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
always:
- name: Remove temporary ACME vhost
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
state: absent
- name: Reload nginx after certificate provisioning
ansible.builtin.systemd:
name: nginx
state: reloaded
+49
View File
@@ -56,6 +56,12 @@
state: started state: started
when: acme_email is defined when: acme_email is defined
- name: Remove default nginx vhost (Arch ships one that conflicts)
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/default.conf"
state: absent
notify: Reload nginx
- name: Ensure nginx conf.d directory exists - name: Ensure nginx conf.d directory exists
ansible.builtin.file: ansible.builtin.file:
path: "{{ nginx_conf_dir }}" path: "{{ nginx_conf_dir }}"
@@ -72,6 +78,49 @@
group: root group: root
mode: "0755" 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 - name: Ensure Certbot webroot directory exists
ansible.builtin.file: ansible.builtin.file:
path: /var/www/certbot 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;
}
-41
View File
@@ -1,41 +0,0 @@
# HTTPS vhost with Let's Encrypt (Certbot) for {{ server_name }}
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
server_name {{ server_name }};
# 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;
server_name {{ server_name }};
# Let's Encrypt certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/{{ server_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ server_name }}/privkey.pem;
# SSL configuration
ssl_protocols {{ nginx_ssl_protocols }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }};
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }};
{% else %}
access_log /var/log/nginx/{{ server_name }}_access.log main;
error_log /var/log/nginx/{{ server_name }}_error.log;
{% endif %}
# Service-specific configuration included below
{{ vhost_config | default('') }}
}
@@ -0,0 +1,16 @@
# Temporary HTTP-only vhost for ACME certificate provisioning
# Managed by Ansible - automatically removed after certificate issuance
server {
listen 80;
listen [::]:80;
server_name {{ certbot_hostname }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 503;
}
}
+16 -2
View File
@@ -60,7 +60,7 @@
- name: Set user home directory fact - name: Set user home directory fact
ansible.builtin.set_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 - name: Create systemd user directory for ntfy
ansible.builtin.file: ansible.builtin.file:
@@ -79,9 +79,17 @@
mode: "0644" mode: "0644"
notify: Reload systemd user 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 }} - name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}" ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root' changed_when: true
when:
- ansible_user != 'root'
- not linger_file.stat.exists
- name: Enable and start ntfy service (user scope) - name: Enable and start ntfy service (user scope)
ansible.builtin.systemd: ansible.builtin.systemd:
@@ -126,6 +134,12 @@
become: false become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for ntfy
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ ntfy_nginx_hostname }}"
when: ntfy_nginx_enabled
- name: Deploy nginx vhost configuration for ntfy - name: Deploy nginx vhost configuration for ntfy
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+2
View File
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ ntfy_nginx_hostname }}; server_name {{ ntfy_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
server_name {{ ntfy_nginx_hostname }}; server_name {{ ntfy_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # Let's Encrypt certificates (managed by Certbot)
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Ntfy Notification Service Description=Ntfy Notification Service
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy
ExecStart=/usr/bin/podman play kube --replace ntfy.yaml ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} ntfy.yaml
ExecStop=/usr/bin/podman play kube --down ntfy.yaml ExecStop=/usr/bin/podman kube down ntfy.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+7 -4
View File
@@ -26,14 +26,17 @@ spec:
- name: ntfy-data - name: ntfy-data
mountPath: /var/lib/ntfy mountPath: /var/lib/ntfy
livenessProbe: livenessProbe:
httpGet: exec:
path: /v1/health command:
port: 80 - wget
- -q
- -O-
- http://localhost:80/v1/health
initialDelaySeconds: 40 initialDelaySeconds: 40
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
failureThreshold: 3 failureThreshold: 3
restartPolicy: Always restartPolicy: Never
volumes: volumes:
- name: localtime - name: localtime
+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
+5
View File
@@ -18,3 +18,8 @@ podman_log_driver: journald
# k8s-file driver settings (only used when podman_log_driver: k8s-file) # k8s-file driver settings (only used when podman_log_driver: k8s-file)
podman_log_max_size: 10mb # Max size per log file before rotation podman_log_max_size: 10mb # Max size per log file before rotation
podman_log_max_files: 5 # Max number of rotated log files to keep podman_log_max_files: 5 # Max number of rotated log files to keep
# Host gateway address exposed inside rootless containers (pasta --map-host-loopback)
# Containers can connect to this address to reach services bound to host loopback.
# Pasta translates the destination to 127.0.0.1 on the host side.
podman_gw_gateway: 100.64.0.1
+1 -1
View File
@@ -9,7 +9,7 @@
- name: Check if tun module is available - name: Check if tun module is available
ansible.builtin.stat: ansible.builtin.stat:
path: "/lib/modules/{{ ansible_kernel }}/modules.builtin" path: "/lib/modules/{{ ansible_facts['kernel'] }}/modules.builtin"
register: kernel_modules register: kernel_modules
- name: Load tun kernel module for rootless Podman networking - name: Load tun kernel module for rootless Podman networking
+8 -1
View File
@@ -29,5 +29,12 @@ runtime = "{{ podman_runtime }}"
network_backend = "netavark" network_backend = "netavark"
[network] [network]
# Default rootless network command (pasta for better performance) # Default rootless network command (pasta for better performance).
# Note: default_rootless_network_cmd only accepts the mode name ("pasta" or
# "slirp4netns"). Extra pasta arguments must be set via pasta_options below;
# the "pasta:--arg=value" syntax is only valid for the CLI --network= flag.
default_rootless_network_cmd = "pasta" default_rootless_network_cmd = "pasta"
# --map-host-loopback exposes the host's loopback to containers via {{ podman_gw_gateway }}.
# Containers connecting to {{ podman_gw_gateway }} reach host services bound to 127.0.0.1.
pasta_options = ["--map-host-loopback", "{{ podman_gw_gateway }}"]
+10 -8
View File
@@ -4,25 +4,27 @@
# This file controls: which hosts are allowed to connect, how clients # This file controls: which hosts are allowed to connect, how clients
# are authenticated, which PostgreSQL user names they can use, which # are authenticated, which PostgreSQL user names they can use, which
# databases they can access. # databases they can access.
#
# Authentication policy:
# - Unix socket: trust (admin access via `become_user: postgres`, e.g. Ansible)
# - All TCP connections: scram-sha-256 (passwords required, including loopback)
# This is required because pasta forwards rootless container traffic via
# host loopback, so containers appear as source 127.0.0.1.
# TYPE DATABASE USER ADDRESS METHOD # TYPE DATABASE USER ADDRESS METHOD
# "local" is for Unix domain socket connections only # "local" is for Unix domain socket connections only
local all all trust local all all trust
# IPv4 local connections: # IPv4 connections (all require password, even loopback):
{% for source in postgres_firewall_allowed_sources %} {% for source in postgres_firewall_allowed_sources %}
{% if source.startswith('127.0.0.') %}
host all all {{ source }} trust
{% else %}
host all all {{ source }} scram-sha-256 host all all {{ source }} scram-sha-256
{% endif %}
{% endfor %} {% endfor %}
# IPv6 local connections: # IPv6 local connections:
host all all ::1/128 trust host all all ::1/128 scram-sha-256
# Allow replication connections from localhost, by a user with the # Allow replication connections from localhost, by a user with the
# replication privilege. # replication privilege.
local replication all trust local replication all trust
host replication all 127.0.0.1/32 trust host replication all 127.0.0.1/32 scram-sha-256
host replication all ::1/128 trust host replication all ::1/128 scram-sha-256
@@ -0,0 +1,15 @@
[Unit]
Description=QFieldCloud Application
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/qfieldcloud
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network {{ qfieldcloud_podman_network }} qfieldcloud.yaml
ExecStop=/usr/bin/podman kube down qfieldcloud.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
@@ -0,0 +1,126 @@
---
apiVersion: v1
kind: Pod
metadata:
name: qfieldcloud
labels:
app: qfieldcloud
spec:
containers:
- name: app
image: {{ qfieldcloud_app_image }}:{{ qfieldcloud_version }}
command:
- gunicorn
- qfieldcloud.wsgi:application
- --bind
- 0.0.0.0:8000
- --timeout
- "{{ qfieldcloud_gunicorn_timeout }}"
- --max-requests
- "{{ qfieldcloud_gunicorn_max_requests }}"
- --workers
- "{{ qfieldcloud_gunicorn_workers }}"
- --threads
- "{{ qfieldcloud_gunicorn_threads }}"
ports:
- containerPort: 8000
hostPort: {{ qfieldcloud_app_port }}
env:
- name: DJANGO_ALLOWED_HOSTS
value: "{{ qfieldcloud_hostname }} localhost 127.0.0.1 app"
- name: DJANGO_SETTINGS_MODULE
value: qfieldcloud.settings
- name: SECRET_KEY
value: "{{ qfieldcloud_secret_key }}"
- name: SALT_KEY
value: "{{ qfieldcloud_salt_key }}"
- name: DEBUG
value: "{{ qfieldcloud_debug }}"
- name: ENVIRONMENT
value: "{{ qfieldcloud_environment }}"
- name: POSTGRES_DB
value: "{{ qfieldcloud_postgres_db_name }}"
- name: POSTGRES_USER
value: "{{ qfieldcloud_postgres_user }}"
- name: POSTGRES_PASSWORD
value: "{{ qfieldcloud_postgres_password }}"
- name: POSTGRES_HOST
value: "{{ qfieldcloud_postgres_host }}"
- name: POSTGRES_PORT
value: "{{ qfieldcloud_postgres_port }}"
- name: POSTGRES_SSLMODE
value: "{{ qfieldcloud_postgres_sslmode }}"
- name: STORAGES
value: '{"default":{"BACKEND":"qfieldcloud.filestorage.backend.QfcS3Boto3Storage","OPTIONS":{"access_key":"{{ qfieldcloud_s3_access_key }}","secret_key":"{{ qfieldcloud_s3_secret_key }}","bucket_name":"{{ qfieldcloud_s3_bucket }}","region_name":"{{ qfieldcloud_s3_region }}","endpoint_url":"{{ qfieldcloud_s3_endpoint_url }}"},"QFC_IS_LEGACY":false}}'
- name: QFIELDCLOUD_HOST
value: "{{ qfieldcloud_hostname }}"
- name: QFIELDCLOUD_ADMIN_URI
value: "{{ qfieldcloud_admin_uri }}"
- name: QFIELDCLOUD_SUBSCRIPTION_MODEL
value: "{{ qfieldcloud_subscription_model }}"
- name: QFIELDCLOUD_ACCOUNT_ADAPTER
value: "{{ qfieldcloud_account_adapter }}"
- name: QFIELDCLOUD_PASSWORD_LOGIN_IS_ENABLED
value: "{{ qfieldcloud_password_login_enabled }}"
- name: QFIELDCLOUD_AUTH_TOKEN_EXPIRATION_HOURS
value: "{{ qfieldcloud_auth_token_expiration_hours }}"
- name: QFIELDCLOUD_USE_I18N
value: "{{ qfieldcloud_use_i18n }}"
- name: QFIELDCLOUD_DEFAULT_LANGUAGE
value: "{{ qfieldcloud_default_language }}"
- name: QFIELDCLOUD_DEFAULT_TIME_ZONE
value: "{{ qfieldcloud_default_timezone }}"
- name: QFIELDCLOUD_WORKER_QFIELDCLOUD_URL
value: http://localhost:8000/api/v1/
- name: QFIELDCLOUD_QGIS_IMAGE_NAME
value: "{{ qfieldcloud_qgis_image }}:{{ qfieldcloud_version }}"
- name: QFIELDCLOUD_DEFAULT_NETWORK
value: {{ qfieldcloud_podman_network }}
- name: ACCOUNT_EMAIL_VERIFICATION
value: "{{ qfieldcloud_account_email_verification }}"
- name: SOCIALACCOUNT_PROVIDERS
value: "{{ qfieldcloud_socialaccount_providers }}"
- name: EMAIL_HOST
value: "{{ qfieldcloud_email_host }}"
- name: EMAIL_PORT
value: "{{ qfieldcloud_email_port }}"
- name: EMAIL_USE_TLS
value: "{{ qfieldcloud_email_use_tls }}"
- name: EMAIL_USE_SSL
value: "{{ qfieldcloud_email_use_ssl }}"
- name: EMAIL_HOST_USER
value: "{{ qfieldcloud_email_host_user }}"
- name: EMAIL_HOST_PASSWORD
value: "{{ qfieldcloud_email_host_password }}"
- name: DEFAULT_FROM_EMAIL
value: "{{ qfieldcloud_email_from }}"
- name: TMP_DIRECTORY
value: /tmp
- name: SENTRY_DSN
value: "{{ qfieldcloud_sentry_dsn }}"
- name: SENTRY_SAMPLE_RATE
value: "{{ qfieldcloud_sentry_sample_rate }}"
- name: SENTRY_RELEASE
value: "{{ qfieldcloud_sentry_release }}"
- name: SENTRY_ENVIRONMENT
value: "{{ qfieldcloud_environment }}"
volumeMounts:
- name: staticfiles
mountPath: /usr/src/app/staticfiles
- name: mediafiles
mountPath: /usr/src/app/mediafiles
restartPolicy: Never
- name: memcached
image: docker.io/library/memcached:1
restartPolicy: Never
volumes:
- name: staticfiles
hostPath:
path: {{ podman_projects_dir | default('/opt/podman') }}/qfieldcloud/staticfiles
type: Directory
- name: mediafiles
hostPath:
path: {{ podman_projects_dir | default('/opt/podman') }}/qfieldcloud/mediafiles
type: Directory
+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 %}
+3 -2
View File
@@ -1,7 +1,8 @@
--- ---
ssh_port: 22 ssh_port: 22
ssh_allowed_network: "192.168.1.0/24" ssh_allowed_networks:
ssh_allowed_vpn_network: "192.168.27.0/27" - { src: "192.168.1.0/24", comment: "SSH from LAN" }
- { src: "192.168.27.0/27", comment: "SSH from VPN" }
ssh_users: "jokester" # space separated if many ssh_users: "jokester" # space separated if many
ssh_config_dir: "/etc/ssh" ssh_config_dir: "/etc/ssh"
sshd_config: "{{ ssh_config_dir }}/sshd_config" sshd_config: "{{ ssh_config_dir }}/sshd_config"
+4 -17
View File
@@ -20,23 +20,15 @@
name: "{{ ssh_service_name }}" name: "{{ ssh_service_name }}"
enabled: true enabled: true
- name: Allow local network incoming connection - name: Allow SSH incoming connections
community.general.ufw: community.general.ufw:
rule: allow rule: allow
port: "{{ ssh_port }}" port: "{{ ssh_port }}"
proto: tcp proto: tcp
from: "{{ ssh_allowed_network }}" from: "{{ item.src }}"
direction: in direction: in
comment: "SSH from local network" comment: "{{ item.comment }}"
loop: "{{ ssh_allowed_networks }}"
- name: Allow SSH VPN incoming connection
community.general.ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
from: "{{ ssh_allowed_vpn_network }}"
direction: in
comment: "SSH from VPN network"
# TODO # TODO
# - name: Add SSH public key to authorized_keys # - name: Add SSH public key to authorized_keys
@@ -105,8 +97,3 @@
enabled: true enabled: true
state: started state: started
- name: Start and enable fail2ban
ansible.builtin.service:
name: fail2ban
state: started
enabled: true
+9
View File
@@ -54,6 +54,15 @@
become_user: "{{ nginx_user }}" become_user: "{{ nginx_user }}"
changed_when: true changed_when: true
- name: Provision TLS certificates for static web sites
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ item.key }}"
loop: "{{ static_web_sites | dict2items }}"
when:
- static_web_sites | length > 0
- item.value.ssl_enabled | default(true)
- name: Deploy nginx vhost configurations - name: Deploy nginx vhost configurations
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+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>
+43
View File
@@ -0,0 +1,43 @@
# sys_autoupdate
Automated system updates and Podman image updates with ntfy notifications.
Supports Arch Linux and Debian/Ubuntu. Deploys a Bash script + systemd timer that runs daily to:
1. Check for distro-specific news requiring manual intervention (Arch only)
2. Apply system updates (`pacman -Syu` / `apt-get dist-upgrade`)
3. Pull latest Podman images and restart pods with updated images
4. Send push notifications via ntfy.sh at each stage
## Configuration
See [defaults/main.yml](defaults/main.yml) for all variables.
Required in host vars:
```yaml
sys_autoupdate_ntfy_topic: your-notification-topic
```
## OS support
| OS | Update command | News check |
|----|---------------|------------|
| Arch Linux | `pacman -Syu --noconfirm` | archlinux.org/news |
| Debian/Ubuntu | `apt-get dist-upgrade -y` | None (stable release) |
OS-specific commands are defined in `vars/archlinux.yml` and `vars/debian.yml`, loaded automatically via `ansible_facts['os_family']`.
## Podman image updates
When `sys_autoupdate_podman_enabled: true` (default), the script scans `podman_projects_dir` for `docker-compose.yml` files, pulls images via `podman-compose pull`, and recreates containers with `podman-compose up -d` for projects with updated images. Dangling images are pruned after each run.
The script runs as root (for package management) and uses `sudo -u {{ ansible_user }}` for Podman operations to preserve rootless isolation.
## Notifications
| Tag | Meaning |
|-----|---------|
| `white_check_mark` | System update succeeded |
| `x` | Update or pod restart failed |
| `warning` | Distro news requires manual review (Arch) |
| `whale` | Podman images updated |
+30
View File
@@ -0,0 +1,30 @@
---
# sys_autoupdate_ntfy_topic: "" # Intentionally undefined - role will fail if not set
sys_autoupdate_ntfy_server: https://ntfy.sh
# sys_autoupdate_ntfy_token: "" # Intentionally undefined - unauthenticated if not set
sys_autoupdate_script_path: /usr/local/bin/sys-autoupdate.sh
# Schedule: daily at 04:00 with up to 60min jitter
sys_autoupdate_update_hour: 4
sys_autoupdate_update_minute: 0
sys_autoupdate_randomized_delay: 60m
# Arch Linux only: check archlinux.org/news before updating
# Ignored on Debian (sys_autoupdate_has_news_check is false in vars/debian.yml)
sys_autoupdate_check_news: true
sys_autoupdate_news_hours: 24
sys_autoupdate_allow_downgrade: false
# Podman image auto-update (rootless, runs as ansible_user)
# Pulls latest images via podman-compose and recreates containers if changed
sys_autoupdate_podman_enabled: true
sys_autoupdate_podman_projects_dir: "{{ podman_projects_dir | default('/opt/podman') }}"
# Prune images older than this duration after update.
# Format: hours suffix (e.g. 720h = 30 days). Set to "" to disable age-based prune
# (only dangling images will be removed in that case).
sys_autoupdate_podman_prune_until: "720h"

Some files were not shown because too many files have changed in this diff Show More