Compare commits

...

28 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 7904275754 nfs: minor tweak 2026-05-22 00:07:24 +02:00
106 changed files with 2934 additions and 236 deletions
+4 -11
View File
@@ -1,11 +1,4 @@
inventory/*
!inventory/hosts.example
!inventory/host_vars/
inventory/host_vars/*
!inventory/host_vars/example.yml
inventory_data/
playbook.yml
playbooks/*
!playbooks/example.yml
!playbooks/bootstrap.yml
TODO.md
/inventory
/inventory_data
/playbooks
/roadmap
+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 |
| vpn | WireGuard server |
## Port Reservation Rules
Reserved ports that **must not** be used as role defaults:
| Port(s) | Protocol | Reserved for |
| --- | --- | --- |
| 80 | tcp | Nginx |
| 443 | tcp | Nginx |
| 3000-3009 | tcp | Testing |
| 4430 | tcp | Testing |
| 8080 | tcp | Testing |
When adding a new role, pick a default port outside these ranges.
## Requirements
Ansible `>=2.15`
Base tools:
```sh
@@ -61,7 +77,8 @@ If you have a password on your ssh key `--ask-pass` is recommended, `--ask-becom
```sh
ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-pass \
--ask-become-pass
--ask-become-pass \
--ask-vault-pass
```
You can also call you ssh agent to unlock your key prior to simplify your calls:
@@ -110,3 +127,17 @@ Linting:
ansible-lint
npx prettier --write .
```
## Q&A
### Immich crash loop: `PostgresError: must be owner of extension vector`
Immich tries to self-update the `pgvector` extension at startup, but its database user is intentionally `NOSUPERUSER`, so the `ALTER EXTENSION vector UPDATE` call fails and the microservices worker exits with code 1.
Fix it on the running host by updating the extension as the `postgres` superuser:
```sh
sudo -u postgres psql -d immich -c 'ALTER EXTENSION vector UPDATE;'
```
The Immich role also runs this automatically on subsequent playbook runs, so re-deployments after a pgvector package upgrade do not require manual intervention.
@@ -14,10 +14,6 @@ network_interfaces:
- name: lan1
type: ethernet
mac_address: 0a:3f:5b:1c:d2:e4
- name: podman-gw
type: bridge
ipv4:
address: "{{ podman_gw_gateway }}/10"
# Unbound DNS resolver configuration
# ----------------------------------
@@ -151,24 +147,24 @@ nfs_bind_addresses:
# Podman configuration
# --------------------
podman_gw_gateway: 100.64.0.1
podman_gw_subnet: 100.64.0.0/10
# Address inside containers that maps to the host's loopback (via pasta
# --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
# ------------------------
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:
- 127.0.0.0/8
- "{{ podman_gw_subnet }}"
# Valkey configuration
# --------------------
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:
- 127.0.0.0/8
- "{{ podman_gw_subnet }}"
# 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:
- name: ansible.netcommon
- name: ansible.posix
version: ">=2.2.0"
- name: community.general
- name: community.postgresql
- name: containers.podman
+2 -2
View File
@@ -2,8 +2,8 @@
[Unit]
Description=IPv4 DHCP server on %I
After=sys-subsystem-net-devices-%i.device network-online.target
Wants=network-online.target
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
[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;
}
}
+1 -1
View File
@@ -102,7 +102,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Gitea
ansible.builtin.file:
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Gitea Git Service
[Service]
Type=oneshot
RemainAfterExit=true
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea
ExecStart=/usr/bin/podman play kube --replace --network=pasta:--map-host-loopback={{ podman_gw_gateway }} gitea.yaml
ExecStop=/usr/bin/podman play kube --down 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 kube down gitea.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+1 -1
View File
@@ -41,7 +41,7 @@ spec:
readOnly: true
- name: gitea-data
mountPath: /data
restartPolicy: Always
restartPolicy: Never
volumes:
- name: localtime
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ gitea_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ gitea_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
+7
View File
@@ -13,6 +13,13 @@ Both passwords must be set in your inventory (min 12 characters):
- `immich_postgres_password` - PostgreSQL database password
- `immich_valkey_password` - Valkey/Redis password
## External Libraries
Mount host paths read-only into the server container via `immich_external_libraries`,
then add the in-container `mount_path` in the Immich UI
(Administration → External Libraries). The `{{ ansible_user }}` running the rootless
pod must have read access on the host path.
## Troubleshooting
### Valkey ACL Issues
+9
View File
@@ -5,6 +5,15 @@ immich_version: release
# Storage location (@see https://docs.immich.app/install/environment-variables/)
immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload"
# External libraries (read-only host paths exposed to the server container)
# Use the in-container `mount_path` when registering the library in the Immich UI.
# Example:
# immich_external_libraries:
# - name: clement-photos
# host_path: /mnt/andromeda/clement-photos
# mount_path: /mnt/external/clement-photos
immich_external_libraries: []
# PostgreSQL configuration (REQUIRED password - must be set explicitly)
immich_postgres_db_name: immich
immich_postgres_user: immich
+1 -1
View File
@@ -124,7 +124,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Immich
ansible.builtin.file:
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Immich Media Server
[Service]
Type=oneshot
RemainAfterExit=true
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich
ExecStart=/usr/bin/podman play kube --replace --network=pasta:--map-host-loopback={{ podman_gw_gateway }} immich.yaml
ExecStop=/usr/bin/podman play kube --down 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 kube down immich.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+13 -2
View File
@@ -45,6 +45,11 @@ spec:
readOnly: true
- name: immich-data
mountPath: /data
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
mountPath: {{ lib.mount_path }}
readOnly: true
{% endfor %}
livenessProbe:
httpGet:
path: /api/server/ping
@@ -53,7 +58,7 @@ spec:
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Always
restartPolicy: Never
- name: machine-learning
image: {{ immich_ml_image }}:{{ immich_version }}
@@ -72,7 +77,7 @@ spec:
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Always
restartPolicy: Never
volumes:
- name: localtime
@@ -83,6 +88,12 @@ spec:
hostPath:
path: {{ immich_upload_location }}
type: Directory
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
hostPath:
path: {{ lib.host_path }}
type: Directory
{% endfor %}
- name: model-cache
persistentVolumeClaim:
claimName: immich-model-cache
+15 -1
View File
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ immich_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,8 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ immich_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
@@ -38,6 +41,12 @@ server {
client_max_body_size 50000M;
# Timeouts for slow mobile uploads (client <-> nginx leg)
client_body_timeout 600s;
client_header_timeout 600s;
send_timeout 600s;
keepalive_timeout 600s;
location / {
proxy_pass http://127.0.0.1:{{ immich_port }};
proxy_set_header Host $http_host;
@@ -50,7 +59,12 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for large file uploads
# Stream uploads directly to backend instead of buffering full body on disk
proxy_request_buffering off;
proxy_buffering off;
# Timeouts for large file uploads (nginx <-> immich leg)
proxy_connect_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
+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:
network_reload_required: true
when: netdev_result is changed or network_result is changed
## Routing & NAT (when interface has forward + masquerade enabled)
- name: Enable IPv4 forwarding
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
sysctl_set: true
reload: true
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
- name: Set UFW default forward policy to ACCEPT
ansible.builtin.lineinfile:
path: /etc/default/ufw
regexp: "^DEFAULT_FORWARD_POLICY="
line: 'DEFAULT_FORWARD_POLICY="ACCEPT"'
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
notify: Restart ufw (ip-forwarding settings changed)
- name: Configure NAT masquerade in UFW before.rules for {{ interface.name }}
ansible.builtin.blockinfile:
path: /etc/ufw/before.rules
insertbefore: "^\\*filter"
marker: "# {mark} ANSIBLE MANAGED - NAT {{ interface.name }}"
block: |
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s {{ interface.ipv4.address | ansible.utils.ipaddr('network/prefix') }} -o {{ interface.ipv4.nat_out_interface }} -j MASQUERADE
COMMIT
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
- interface.ipv4.nat_out_interface is defined
notify: Restart ufw (ip-forwarding settings changed)
@@ -14,6 +14,12 @@ RouteMetric={{ interface.ipv4.metric }}
{% if interface.type is defined and interface.type == 'bridge' %}
ConfigureWithoutCarrier=yes
{% endif %}
{% if interface.ipv4.forward | default(false) %}
IPForward=ipv4
{% endif %}
{% if interface.ipv4.masquerade | default(false) %}
IPMasquerade=ipv4
{% endif %}
{% if interface.ipv4.nameservers is defined %}
{% for dns in interface.ipv4.nameservers %}
DNS={{ dns }}
+1 -1
View File
@@ -7,7 +7,7 @@
- name: Process ethernet interface persistence
when: interface.type is not defined or interface.type == 'ethernet'
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:
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 }}"
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
ansible.builtin.systemd:
name: "{{ item }}"
+4
View File
@@ -16,3 +16,7 @@ nfs_port: 2049
nfs_server_firewall_allowed_sources:
- 127.0.0.0/8
# OS-dependent package name
nfs_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}
+6 -1
View File
@@ -1,7 +1,7 @@
---
- name: Install nfs-server
ansible.builtin.package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}"
name: "{{ nfs_package_name }}"
state: present
- name: Configure nfs configuration
@@ -28,6 +28,11 @@
state: started
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
community.general.ufw:
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
- **Native ACME/Let's Encrypt support** (Nginx 1.25.0+)
- **Transparent proxy forwarding** (HTTP/HTTPS to other hosts)
- **Catch-all `default_server`** that rejects unknown SNI/Host with `444`
## Catch-all default_server
A `00-default.conf` vhost is deployed and marked `default_server` on both
ports 80 and 443. It uses a self-signed cert (`/etc/nginx/ssl/default.crt`)
and returns `444` (close connection) for any request whose SNI/Host does
not match an explicit vhost. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`)
are still answered on port 80 so Certbot keeps working for new hostnames.
Without this, clients hitting the server IP directly (or doing HTTP/2
connection coalescing across vhosts sharing the same IP) would receive the
certificate of the first vhost loaded alphabetically, leaking that
hostname and breaking TLS verification on other vhosts.
Disable with `nginx_default_server_enabled: false`.
## Service Integration Pattern
+6
View File
@@ -17,6 +17,12 @@ nginx_client_max_body_size: 100M
# SSL configuration (volontarily omit TLSv1.2 here)
nginx_ssl_protocols: TLSv1.3
# Catch-all default_server (rejects unknown SNI / Host with 444).
# Prevents leaking the first-loaded vhost's cert to unrelated requests.
nginx_default_server_enabled: true
nginx_default_ssl_cert: /etc/nginx/ssl/default.crt
nginx_default_ssl_key: /etc/nginx/ssl/default.key
# Logging configuration
# Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal)
nginx_log_backend: journald
+10
View File
@@ -29,6 +29,16 @@
path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
register: certbot_cert_file
- name: Ensure letsencrypt directories are traversable by nginx
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
when: certbot_cert_file.stat.exists
- name: Provision certificate for {{ certbot_hostname }}
when: not certbot_cert_file.stat.exists
block:
+43
View File
@@ -78,6 +78,49 @@
group: root
mode: "0755"
- name: Configure catch-all default_server
when: nginx_default_server_enabled
block:
- name: Ensure nginx ssl directory exists
ansible.builtin.file:
path: "{{ nginx_default_ssl_cert | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Generate self-signed cert for default_server
ansible.builtin.command:
cmd: >-
openssl req -x509 -nodes -newkey rsa:2048
-keyout {{ nginx_default_ssl_key }}
-out {{ nginx_default_ssl_cert }}
-days 3650 -subj "/CN=default"
creates: "{{ nginx_default_ssl_cert }}"
- name: Restrict permissions on default_server key
ansible.builtin.file:
path: "{{ nginx_default_ssl_key }}"
owner: root
group: root
mode: "0600"
- name: Deploy default_server vhost
ansible.builtin.template:
src: default-server.conf.j2
dest: "{{ nginx_conf_dir }}/00-default.conf"
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Remove default_server vhost when disabled
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/00-default.conf"
state: absent
when: not nginx_default_server_enabled
notify: Reload nginx
- name: Ensure Certbot webroot directory exists
ansible.builtin.file:
path: /var/www/certbot
@@ -0,0 +1,35 @@
# Catch-all default_server vhosts
# Managed by Ansible - DO NOT EDIT MANUALLY
#
# Purpose: reject any request whose Host/SNI does not match an explicit
# server_name. Without this, the first vhost loaded alphabetically would
# leak its certificate to unrelated SNI requests (e.g. clients doing
# HTTP/2 connection coalescing or hitting the IP directly).
#
# `return 444` closes the connection without sending an HTTP response.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Keep ACME HTTP-01 challenges working for any hostname
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 444;
}
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate {{ nginx_default_ssl_cert }};
ssl_certificate_key {{ nginx_default_ssl_key }};
return 444;
}
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ certbot_hostname }};
location /.well-known/acme-challenge/ {
+1 -1
View File
@@ -60,7 +60,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for ntfy
ansible.builtin.file:
+2
View File
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ ntfy_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ ntfy_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Ntfy Notification Service
[Service]
Type=oneshot
RemainAfterExit=true
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy
ExecStart=/usr/bin/podman play kube --replace --network=pasta:--map-host-loopback={{ podman_gw_gateway }} ntfy.yaml
ExecStop=/usr/bin/podman play kube --down 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 kube down ntfy.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+7 -4
View File
@@ -26,14 +26,17 @@ spec:
- name: ntfy-data
mountPath: /var/lib/ntfy
livenessProbe:
httpGet:
path: /v1/health
port: 80
exec:
command:
- wget
- -q
- -O-
- http://localhost:80/v1/health
initialDelaySeconds: 40
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Always
restartPolicy: Never
volumes:
- 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
+1 -1
View File
@@ -9,7 +9,7 @@
- name: Check if tun module is available
ansible.builtin.stat:
path: "/lib/modules/{{ ansible_kernel }}/modules.builtin"
path: "/lib/modules/{{ ansible_facts['kernel'] }}/modules.builtin"
register: kernel_modules
- name: Load tun kernel module for rootless Podman networking
+10 -8
View File
@@ -4,25 +4,27 @@
# This file controls: which hosts are allowed to connect, how clients
# are authenticated, which PostgreSQL user names they can use, which
# 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
# "local" is for Unix domain socket connections only
local all all trust
# IPv4 local connections:
# IPv4 connections (all require password, even loopback):
{% 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
{% endif %}
{% endfor %}
# 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
# replication privilege.
local replication all trust
host replication all 127.0.0.1/32 trust
host replication all ::1/128 trust
host replication all 127.0.0.1/32 scram-sha-256
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 %}
+48
View File
@@ -0,0 +1,48 @@
# syncthing
Installs and configures [Syncthing](https://syncthing.net/) as a system service.
Runs as a dedicated `syncthing` system user via `syncthing@syncthing.service`.
Supports Arch Linux and Debian-based distributions.
## Required variables
Set these in `inventory/host_vars/<host>.yml`:
```yaml
syncthing_gui_user: admin
syncthing_gui_password: "{{ vault_syncthing_gui_password }}"
```
`syncthing_gui_password` must be at least 12 characters. Set the actual value
in your vault file and reference it via `vault_syncthing_gui_password`.
Syncthing will bcrypt-hash the password on first start.
## Optional variables
See `defaults/main.yml` for the full list. Key options:
| Variable | Default | Description |
|-----------------------------|--------------------------------|------------------------------------|
| `syncthing_user` | `syncthing` | OS user to run syncthing as |
| `syncthing_home` | `/var/lib/syncthing` | Home directory for the system user |
| `syncthing_config_dir` | `{{ syncthing_home }}/.config/syncthing` | Config directory |
| `syncthing_gui_bind` | `0.0.0.0` | GUI listen address |
| `syncthing_gui_port` | `8384` | GUI listen port |
| `syncthing_port` | `22000` | Sync protocol port (TCP) |
| `syncthing_allowed_networks` | `[]` | UFW rules for GUI and sync ports |
## Notes
- `config.xml` is written only on first run — the task is skipped on subsequent
runs if the file already exists. Syncthing manages the file after that (device
ID, folder config, hashed password). Re-running the playbook is safe.
- Folder and device pairing must be done via the Syncthing web UI or REST API
after the service is running.
- The GUI binds to `0.0.0.0` by default — use `syncthing_allowed_networks` to
restrict access via UFW to specific LAN/VPN ranges.
## Debian notes
The `syncthing` package in some Debian versions may be outdated. Consider adding
the [official APT repository](https://apt.syncthing.net/) before applying this role.
+27
View File
@@ -0,0 +1,27 @@
---
# System user to run syncthing as
syncthing_user: syncthing
syncthing_group: syncthing
syncthing_home: /var/lib/syncthing
# Config directory (syncthing reads and writes this at runtime)
syncthing_config_dir: "{{ syncthing_home }}/.config/syncthing"
# GUI credentials (REQUIRED - must be set explicitly)
# syncthing_gui_user: "" # Intentionally undefined - role will fail if not set
# syncthing_gui_password: "" # Intentionally undefined - role will fail if not set
# GUI listen address and port
syncthing_gui_bind: "0.0.0.0"
syncthing_gui_port: 8384
# Sync protocol port (TCP)
syncthing_port: 22000
# Package and service names
syncthing_package: syncthing
syncthing_service: "syncthing@{{ syncthing_user }}"
# Firewall rules - list of allowed source ranges for GUI and sync ports
syncthing_allowed_networks:
- { src: "127.0.0.1/8", comment: "Localhost" }
+5
View File
@@ -0,0 +1,5 @@
---
- name: Restart syncthing
ansible.builtin.systemd:
name: "{{ syncthing_service }}"
state: restarted
+81
View File
@@ -0,0 +1,81 @@
---
- name: Validate required variables are set
ansible.builtin.assert:
that:
- syncthing_gui_user is defined
- syncthing_gui_user | length >= 1
- syncthing_gui_password is defined
- syncthing_gui_password | length >= 12
fail_msg: |
syncthing_gui_user and syncthing_gui_password are required.
syncthing_gui_password must be at least 12 characters.
See roles/syncthing/defaults/main.yml for configuration instructions.
- name: Install syncthing
ansible.builtin.package:
name: "{{ syncthing_package }}"
state: present
- name: Create syncthing system group
ansible.builtin.group:
name: "{{ syncthing_group }}"
system: true
state: present
- name: Create syncthing system user
ansible.builtin.user:
name: "{{ syncthing_user }}"
group: "{{ syncthing_group }}"
home: "{{ syncthing_home }}"
shell: /sbin/nologin
system: true
create_home: true
state: present
- name: Create syncthing config directory
ansible.builtin.file:
path: "{{ syncthing_config_dir }}"
state: directory
owner: "{{ syncthing_user }}"
group: "{{ syncthing_group }}"
mode: "0700"
- name: Check if syncthing config already exists
ansible.builtin.stat:
path: "{{ syncthing_config_dir }}/config.xml"
register: syncthing_config_stat
- name: Deploy initial syncthing config (skipped if already exists)
ansible.builtin.template:
src: config.xml.j2
dest: "{{ syncthing_config_dir }}/config.xml"
owner: "{{ syncthing_user }}"
group: "{{ syncthing_group }}"
mode: "0600"
when: not syncthing_config_stat.stat.exists
notify: Restart syncthing
- name: Allow syncthing GUI and sync traffic through firewall
community.general.ufw:
rule: allow
port: "{{ item.1.port }}"
proto: tcp
from: "{{ item.0.src }}"
direction: in
comment: "{{ item.0.comment }}"
loop: "{{ syncthing_allowed_networks | product(syncthing_ufw_ports) | list }}"
vars:
syncthing_ufw_ports:
- { port: "{{ syncthing_gui_port }}" }
- { port: "{{ syncthing_port }}" }
when: syncthing_allowed_networks | length > 0
retries: 5
delay: 2
register: ufw_result
until: ufw_result is succeeded
- name: Enable and start syncthing service
ansible.builtin.systemd:
name: "{{ syncthing_service }}"
enabled: true
state: started
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Managed by Ansible - initial bootstrap only, DO NOT EDIT MANUALLY -->
<!-- Syncthing will manage this file after first start -->
<configuration version="37">
<gui enabled="true" tls="false" debugging="false">
<address>{{ syncthing_gui_bind }}:{{ syncthing_gui_port }}</address>
<user>{{ syncthing_gui_user }}</user>
<!-- Plaintext password; syncthing will bcrypt-hash it on first start -->
<password>{{ syncthing_gui_password }}</password>
<theme>default</theme>
<insecureSkipHostcheck>false</insecureSkipHostcheck>
</gui>
<options>
<startBrowser>false</startBrowser>
<crashReportingEnabled>false</crashReportingEnabled>
<!-- Opt out of anonymous usage reporting -->
<urAccepted>-1</urAccepted>
<!-- Disable automatic upgrades (managed by package manager) -->
<autoUpgradeIntervalH>0</autoUpgradeIntervalH>
</options>
</configuration>
+6
View File
@@ -0,0 +1,6 @@
---
# OS-dependent package names
tooling_dig_package: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('bind', 'dnsutils') }}
tooling_netcat_package: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('openbsd-netcat', 'netcat-openbsd') }}
+15
View File
@@ -0,0 +1,15 @@
---
- name: Install command-line tooling
ansible.builtin.package:
name:
- usbutils
- htop
- bottom
- wget
- ethtool
- iperf3
- vim
- nano
- "{{ tooling_dig_package }}"
- "{{ tooling_netcat_package }}"
state: present
-60
View File
@@ -1,60 +0,0 @@
---
- name: Install usbutils
package:
name: usbutils
state: present
changed_when: false
- name: Install htop
package:
name: htop
state: present
changed_when: false
- name: Install bottom
package:
name: bottom
state: present
changed_when: false
- name: Install wget
package:
name: wget
state: present
changed_when: false
- name: Install dig utility
package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('bind', 'dnsutils') }}"
state: present
changed_when: false
- name: Install ethtool
package:
name: ethtool
state: present
changed_when: false
- name: Install iperf3
package:
name: iperf3
state: present
changed_when: false
- name: Install vim
package:
name: vim
state: present
changed_when: false
- name: Install nano
package:
name: nano
state: present
changed_when: false
- name: Install netcat
package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('openbsd-netcat', 'netcat-openbsd') }}"
state: present
changed_when: false
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=UniFi Network Controller
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/unifi
ExecStart=/usr/bin/podman kube play --replace --service-container=true unifi.yaml
ExecStop=/usr/bin/podman kube down unifi.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+38
View File
@@ -0,0 +1,38 @@
---
apiVersion: v1
kind: Pod
metadata:
name: unifi
labels:
app: unifi
spec:
hostNetwork: true
containers:
- name: controller
image: {{ unifi_image }}:{{ unifi_version }}
env:
- name: TZ
value: "{{ unifi_timezone }}"
- name: SYSTEM_IP
value: "{{ unifi_bind_address }}"
- name: JVM_MAX_HEAP_SIZE
value: "{{ unifi_jvm_max_heap_size }}"
- name: UNIFI_STDOUT
value: "true"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: unifi-data
mountPath: /unifi
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: unifi-data
hostPath:
path: {{ unifi_data_dir }}
type: Directory
+1 -1
View File
@@ -38,7 +38,7 @@
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}"
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for uptime-kuma
ansible.builtin.file:
@@ -3,6 +3,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ uptime_kuma_nginx_hostname }};
# Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ uptime_kuma_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
@@ -2,13 +2,14 @@
Description=Uptime Kuma Monitoring Service
[Service]
Type=oneshot
RemainAfterExit=true
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/uptime-kuma
ExecStart=/usr/bin/podman play kube --replace uptime-kuma.yaml
ExecStop=/usr/bin/podman play kube --down uptime-kuma.yaml
ExecStart=/usr/bin/podman kube play --replace --service-container=true uptime-kuma.yaml
ExecStop=/usr/bin/podman kube down uptime-kuma.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
@@ -21,7 +21,7 @@ spec:
readOnly: true
- name: uptime-kuma-data
mountPath: /app/data
restartPolicy: Always
restartPolicy: Never
volumes:
- name: localtime
+18 -6
View File
@@ -1,9 +1,21 @@
---
wireguard_primary_interface: "{{ network_interfaces.0.name }}"
wireguard_port: 51820
wireguard_interface: wg0
wireguard_config_base_path: /etc/wireguard
wireguard_server_mode: true # enables NAT and open port
# wireguard_address: 192.168.27.1/27 # Intentionally undefined - role will fail if not set
# wireguard_dns: 192.168.27.1 # Intentionally undefined - role will fail if not set
wireguard_peers: []
# wireguard_tunnels:
# - interface: wg0 # required: maps to wgN interface name and config filename
# address: 10.0.0.1/24 # required: CIDR address for [Interface] Address
# port: 51820 # optional: ListenPort (required in server_mode)
# dns: 10.0.0.1 # optional: DNS= line; omit to suppress
# server_mode: true # optional (default: false): enables NAT masquerade + UFW rule
# primary_interface: eth0 # optional: overrides wireguard_primary_interface for this tunnel
# peers: # optional: list of [Peer] entries
# - name: peer_name # required: comment label
# public_key: ... # required: peer's public key
# allowed_ips: [10.0.0.0/24] # required: list of CIDRs
# endpoint: host:port # optional: peer's public endpoint
# persistent_keepalive: 25 # optional: keepalive interval (seconds)
wireguard_tunnels: []
# OS-dependent package name
wireguard_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('wireguard-tools', 'wireguard') }}
+4
View File
@@ -2,3 +2,7 @@
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Apply sysctl
ansible.builtin.command: sysctl --system
changed_when: true

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