Compare commits

...

63 Commits

Author SHA1 Message Date
Clément Désiles 21f47196cf feat(net_config): support IPForward and IPMasquerade in network config 2026-06-19 23:55:02 +02:00
Clément Désiles e4ad6888b6 Introduce vault pass in the doc to push a good practice 2026-06-19 23:52:19 +02:00
Clément Désiles 4c57d28b4d Add new fdroid role to host custom apks 2026-06-19 23:50:56 +02:00
Clément Désiles 045c0b9bec refactor(zfs): inline dataset ownership, add absent cleanup
Drop separate dataset-ownership.yml task file. Use
extra_zfs_properties.mountpoint directly instead of zfs get.
Add rmdir cleanup for absent dataset mountpoints.
2026-06-19 23:46:33 +02:00
Clément Désiles 13b8aae769 Add support for NUT (EATON inverter) 2026-06-13 09:37:49 +02:00
Clément Désiles 25621a101c Merge branch 'main' of github.com:cdesiles/ansible-playbooks 2026-06-12 22:34:24 +02:00
Clément Désiles 0726e417d2 feat: add syncthing support 2026-06-03 23:28:26 +02:00
Clément Désiles 2f3eebd422 feat: add metabase role 2026-06-03 10:01:00 +02:00
Clément Désiles d976a9d701 fix: cleanup examples 2026-05-31 22:42:12 +02:00
Clément Désiles e74fffd5fc refacto: move inventory examples to a dedicated dir 2026-05-31 22:34:31 +02:00
Clément Désiles 30dfb9ee8b feat(immich): support read-only external libraries
Add immich_external_libraries variable to mount host paths into the
server container, intended for use with Immich's External Libraries
feature. Mounts are read-only; the in-container mount_path must be
used when registering the library in the Immich UI.
2026-05-30 23:39:17 +02:00
Clément Désiles b0324cf3fe refactor: hoist OS-specific package names to role defaults
- nfs_server: nfs_package_name (nfs-utils / nfs-kernel-server)
- wireguard: wireguard_package_name (wireguard-tools / wireguard)
- tooling:   tooling_dig_package, tooling_netcat_package

Also fix tooling role structure: move tooling.yml to tasks/main.yml so
the role is actually invokable via 'role: tooling' (defaults/main.yml
is auto-loaded), and collapse the 10 individual package tasks into a
single list-based install.
2026-05-30 21:57:20 +02:00
Clément Désiles a6ca97ca0e feat(samba_server): new role for SMB/CIFS shares
Mirrors the nfs_server design: standalone tdbsam server, per-share access
control (valid_users, write_list, force_user/group), optional guest fallback
(map to guest = Bad User), UFW rules for ports 445/139, testparm-validated
config, idempotent smbpasswd user creation.
2026-05-30 21:57:13 +02:00
Clément Désiles b2a66099aa fix(immich): prevent client SocketTimeoutException on large uploads
Add missing nginx directives recommended by Immich docs for mobile
photo backup over slow links:

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

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

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

Changes:
- Add catch-all 00-default.conf default_server on :80 and :443 (v4+v6)
  with a self-signed cert and 'return 444'. ACME challenges still
  answered on :80.
- Add IPv6 listeners ([::]:80 and [::]:443 ssl) to immich, gitea, ntfy,
  uptime_kuma vhosts and to the temporary ACME provisioning vhost.
- Apply 0755 on /etc/letsencrypt/live and /etc/letsencrypt/archive on
  every run, not only at initial cert provisioning.
2026-05-30 17:06:10 +02:00
Clément Désiles 80026fac0b fix: pin ansible.posix >=2.2.0 to silence _text deprecation warning 2026-05-30 17:05:58 +02:00
Clément Désiles c9e2ff930c feat(net_config): safer ufw restart on NAT/forwarding changes
- Replace 'ufw disable && ufw --force enable' single-shot handler with a
  block that dry-runs the ruleset, disables, re-enables, then verifies
  ufw is active. No '&&' short-circuit, so failures are loud instead of
  leaving the host firewall-less.
- Rename handler to 'Restart ufw (ip-forwarding settings changed)' to
  reflect that this is a full restart (required to pick up
  /etc/default/ufw and /etc/ufw/before.rules changes per ufw(8)).
- Add NAT/masquerade tasks: enable ipv4 forwarding, set
  DEFAULT_FORWARD_POLICY=ACCEPT, and write a per-interface *nat block
  in /etc/ufw/before.rules.
- Declare requires_ansible >=2.15 in meta/runtime.yml (handler uses
  block:, supported since 2.12; 2.15 is a safe modern floor).
- README: document Ansible version requirement, port reservation
  rules, and Immich pgvector Q&A.
2026-05-29 22:24:16 +02:00
Clément Désiles 36d6baaecb fix: missing task in wg 2026-05-29 21:54:25 +02:00
Clément Désiles 5f2c82d296 fix: use ansible_facts['getent_passwd'] to silence INJECT_FACTS_AS_VARS deprecation 2026-05-29 21:54:03 +02:00
Clément Désiles dbc7ca203a fix: minor taks name typo 2026-05-29 21:50:39 +02:00
Clément Désiles a8545fc501 fix(podman): use Type=notify + service-container so systemd sees pod crashes
The previous Type=oneshot + RemainAfterExit=true pattern made systemd
freeze pod units in 'active (exited)' as soon as 'podman play kube'
returned, so crash-looping containers were invisible to
'systemctl --user --failed' and Restart=on-failure never fired.

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

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

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

CLAUDE.md updated to document the new canonical template and the
'restartPolicy: Never' requirement.
2026-05-29 21:49:13 +02:00
Clément Désiles 29d9f27052 fix: ntfy probe without curl available 2026-05-29 21:41:04 +02:00
Clément Désiles b04939d3d0 fix: use ansible_facts['kernel'] to avoid deprecation warning 2026-05-29 21:35:54 +02:00
Clément Désiles ff3133f8e7 feat: wireguard role allow multiple endpoints 2026-05-29 21:32:08 +02:00
Clément Désiles 4ae7721070 fix: secure pg + fix old way of sharing podman network 2026-05-29 21:31:07 +02:00
Clément Désiles ffeff6556b fix: restart dhcpd on nas restart 2026-05-29 21:29:14 +02:00
Clément Désiles 436fba0d39 Merge branch 'main' of github.com:cdesiles/ansible-playbooks 2026-05-29 21:28:41 +02:00
Clément Désiles 92deb854d2 fix: enhance tooling 2026-05-29 21:27:25 +02:00
Clément Désiles 05e7ee3956 fix: tls for static web 2026-05-29 21:27:00 +02:00
Clément Désiles aea450dc9d feat: nginx certbot 2026-05-29 21:26:17 +02:00
Clément Désiles 1d00432061 fix: podman integration 2026-05-29 21:24:58 +02:00
Clément Désiles 7904275754 nfs: minor tweak 2026-05-22 00:07:24 +02:00
Clément Désiles 305b8324db feat: sys autoupdate 2026-05-08 23:47:21 +02:00
Clément Désiles ea0771a5ac fix: update wireguard example 2026-05-05 22:59:46 +02:00
Clément Désiles 48e87f7cb1 zsh: enhance configuration with alacritty 2026-05-05 22:58:56 +02:00
Clément Désiles 4ac40b9898 fix: nginx defaults override on archlinux 2026-05-05 22:56:08 +02:00
Clément Désiles 488be1280c fix: dhcpd startup dependency 2026-05-05 22:54:56 +02:00
Clément Désiles de165f5e1c fix: review archlinux install details 2026-04-11 22:55:03 +02:00
Clément Désiles f9397ad38c feat: allow sshd to bind on multiple networks 2026-04-11 22:54:35 +02:00
Clément Désiles ac40c23d06 feat: more base tools 2026-04-11 22:53:19 +02:00
Clément Désiles 6fc7879648 fix: uptime-kuma reverse proxy config 2026-04-11 22:53:06 +02:00
Clément Désiles c4136ba5d2 fix: ntfy 2026-04-11 22:52:29 +02:00
Clément Désiles 4d60c6ea34 fix: zshrc to take p10k theme 2026-04-11 22:51:28 +02:00
Clément Désiles ae33184aa0 fix: zsh role add fzf support and p10k settings tweaks 2026-04-11 22:41:10 +02:00
Clément Désiles 61c88045f7 feat: add more tooling 2026-03-17 23:13:02 +01:00
Clément Désiles 525868caaf fix: wireguard on archlinux 2026-03-17 23:10:08 +01:00
Clément Désiles 235881aba7 fix: commit bootstrap playbook 2026-03-17 23:09:47 +01:00
Clément Désiles a6878c0b7d fix: dhcpd ipv4 service 2026-03-17 23:09:29 +01:00
Clément Désiles e209a93a78 feat: BREAKING unbound configuration 2026-03-17 23:08:44 +01:00
Clément Désiles 869727d364 fix: add bootstrap for new hosts 2026-03-17 23:06:42 +01:00
Clément Désiles 6393ff6ed3 fix: force images pull and change default ports 2026-02-14 21:02:51 +01:00
Clément Désiles 23c7da84bb fix: minor doc 2026-02-03 22:11:35 +01:00
Clément Désiles 34da95f8be fix: ntfy timezone 2026-02-03 22:08:48 +01:00
Clément Désiles 321a14a108 fix: increase unbound ttl&cache 2026-02-03 22:08:20 +01:00
Clément Désiles 1f758deb82 feat: add dhcpd server role 2026-02-03 22:07:40 +01:00
Clément Désiles 5fb027c446 fix: open wg port only in server mode 2026-01-22 07:31:45 +01:00
Clément Désiles 94dfe36c46 fix: add dig to test our dns setup 2026-01-18 13:46:37 +01:00
Clément Désiles 5a880d5d5a fix: unbound idempotency 2026-01-18 13:46:16 +01:00
Clément Désiles 8d3db69172 fix: wireguard config failfast 2026-01-18 13:21:37 +01:00
Clément Désiles aa5de65d30 fix: unbound ipv6 localhost 2026-01-18 13:21:15 +01:00
Clément Désiles c79c445a23 chore: ansible-lint review (almost done) 2026-01-04 11:21:15 +01:00
Clément Désiles 3e469fa25e fix: unbound interface naming 2025-12-24 17:09:22 +01:00
Clément Désiles 08364cf2c8 fix: unbound boot ordering 2025-12-24 16:47:38 +01:00
195 changed files with 7374 additions and 823 deletions
+1
View File
@@ -1,3 +1,4 @@
--- ---
skip_list: skip_list:
- var-naming[no-role-prefix] - var-naming[no-role-prefix]
- no-handler # Sequential task flows require immediate execution, not end-of-play handlers
+4 -10
View File
@@ -1,10 +1,4 @@
inventory/* /inventory
!inventory/hosts.example /inventory_data
!inventory/host_vars/ /playbooks
inventory/host_vars/* /roadmap
!inventory/host_vars/example.yml
inventory_data/
playbook.yml
playbooks/*
!playbooks/example.yml
TODO.md
+357
View File
@@ -0,0 +1,357 @@
# Project Architecture & Patterns
## Limit documentation
- Only one README per role and a general project README.
- Default vars are already documented in ./roles/<role>/defaults/main.yml, no need to document them elsewhere. A link to this file from ./roles/<role>/README.md is sufficient.
## Target Audience
This repository targets **power users, developers, and homelab enthusiasts** who are comfortable with:
- Command-line interfaces and SSH
- Basic networking and Linux system administration
- Reading technical documentation and following references
- Ansible concepts (roles, playbooks, inventory)
**Documentation Philosophy:**
- Straight to the point, no hand-holding
- Minimal redundancy - references between documents preferred
- Assumes familiarity with underlying technologies
- Technical accuracy over verbosity
## Overview
This Ansible repository manages NAS/homelab infrastructure with a focus on:
- **Shared services pattern**: System-level PostgreSQL, Valkey, Nginx
- **Service isolation**: Separate databases and users per service
- **Security first**: Localhost-only access, minimal privileges, fail-fast validation
- **Modularity**: Independent service deployments
## Key Architectural Decisions
### 1. Shared Services Pattern
**Why:** Resource efficiency, easier maintenance, better isolation
**Implementation:**
- Single PostgreSQL instance serves all services
- Single Valkey instance serves all services
- Each service creates its own database/user (PostgreSQL)
- Each service gets its own ACL user (Valkey)
### 2. Multi-Layer Isolation
#### PostgreSQL Isolation
Each service:
- Gets its own database
- Gets its own user with minimal privileges
- Cannot access other services' data
**Security:**
- `NOSUPERUSER`: Cannot create other superusers
- `NOCREATEDB`: Cannot create databases
- `NOCREATEROLE`: Cannot create roles
- `priv: ALL` on own database only
#### Valkey/Redis Isolation
**Important:** Valkey ACL files do NOT support comments. The `users.acl` file must contain only ACL rules, one per line. All documentation should be in role README files, not in the ACL file itself.
Each service gets its own ACL user with:
- Unique credentials (username/password)
- Key pattern restrictions (can only access specific key prefixes)
- Command restrictions (deny dangerous commands like FLUSHDB, KEYS, CONFIG)
- Database number assignment (0-15) for additional logical separation
**Security (ACL-based):**
- Key patterns: `~immich_bull*` restricts access to matching keys only
- Command groups: `-@dangerous` denies FLUSHDB, FLUSHALL, KEYS, CONFIG, etc.
- Selective grants: `+@read +@write +@pubsub` only allows necessary operations
- Channel access: `&*` for pub/sub (job queues like BullMQ)
- Lua scripting: `+eval +evalsha` only when required (e.g., BullMQ)
**Defense-in-depth:**
1. ACL users with restricted permissions (primary)
2. Database number isolation (secondary)
3. Key pattern enforcement (tertiary)
### 3. Container-to-Host Communication
**Challenge:** Containers need to reach system PostgreSQL/Valkey
**Solution:**
- Rootless Podman with `pasta` networking and `--map-host-loopback={{ podman_gw_gateway }}`
- Inside containers, the host's loopback is reachable at `{{ podman_gw_gateway }}` (default `100.64.0.1`)
- Use `host.containers.internal` (resolves to the same address) or the literal gateway IP
- Avoids insecure `network_mode: host`
The default is configured in `/etc/containers/containers.conf` via the
`podman` role:
```ini
[network]
default_rootless_network_cmd = "pasta"
pasta_options = ["--map-host-loopback", "100.64.0.1"]
```
Note: `default_rootless_network_cmd` only affects `podman run`. `podman play
kube` defaults to a bridge network, which rootless users cannot create. Pod
services must pass the flag explicitly on the CLI:
```
ExecStart=/usr/bin/podman play kube --replace \
--network=pasta:--map-host-loopback={{ podman_gw_gateway }} myservice.yaml
```
### 4. Nginx Reverse Proxy Pattern
**Why:** Independent deployments, zero-downtime reloads
**Implementation:**
- Each service deploys its own vhost config to `{{ nginx_conf_dir }}/<service>.conf`
- Default `nginx_conf_dir: /etc/nginx/conf.d` (configurable in inventory)
- Services control exposure via `<service>_nginx_enabled` variable
- Nginx reloads gracefully when configs change
- **Always use `{{ nginx_conf_dir }}` variable, never hardcode paths**
### 5. OS Abstraction
**Why:** Support multiple distributions (Arch Linux, Debian/Ubuntu)
**Implementation:**
```
roles/postgres/vars/
├── archlinux.yml # Arch-specific (user: postgres, package: postgresql)
└── debian.yml # Debian-specific (user: postgres, package: postgresql)
```
Tasks load: `with_first_found: ["{{ ansible_facts['os_family'] }}.yml", "debian.yml"]`
## Creating a New Service Role
### 1. Directory Structure
```
roles/myservice/
├── defaults/main.yml # Variables
├── tasks/main.yml # Main tasks
├── handlers/main.yml # Handlers
├── templates/
│ ├── myservice.yaml.j2 # Kubernetes Pod spec (if containerized)
│ ├── myservice.service.j2 # systemd user unit (if containerized)
│ └── nginx-vhost.conf.j2 # If web-accessible
├── meta/main.yml # Dependencies
└── README.md # Documentation
```
### 2. Meta Dependencies
```yaml
dependencies:
- role: podman # If using containers
- role: postgres # If needs database
- role: redis # If needs cache
```
**Important:** Only include dependencies that are **always** required. Optional dependencies (like nginx for reverse proxy) should be added explicitly in playbooks, not in `meta/main.yml`.
### 3. Rootless Podman and User Systemd Services
**Architecture:** This is a single-user administrative server running rootless Podman. All containerized services:
- Run as `{{ ansible_user }}` (rootless Podman)
- Have files owned by `{{ ansible_user }}`
- Use systemd user services (not system services)
- Require lingering to start at boot without login
**Critical Implementation Details:**
1. **User Systemd Services Template:**
```jinja2
[Unit]
Description=My Service
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/myservice
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} myservice.yaml
ExecStop=/usr/bin/podman kube down myservice.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
```
**Why `podman kube play --service-container=true`:** All containerized
services in this repo use Kubernetes Pod manifests deployed via
`podman kube play`. The `--service-container=true` flag spawns an extra
long-lived container whose lifetime mirrors the pod, so the systemd unit's
main PID stays alive and emits sd_notify. This is what makes
`Type=notify`, `Restart=on-failure`, and `systemctl --user --failed` work
correctly — without it, `kube play` exits 0 immediately and systemd stays
in `active (exited)` forever, even while containers inside the pod
crash-loop. The explicit `--network=pasta:...` flag is required because
`podman kube play` defaults to a bridge network, which rootless users
cannot create (see section 3).
**Do not use** `Type=oneshot` + `RemainAfterExit=true` for podman pods.
That pattern silently hides crash loops from systemd.
**Pod manifest must use `restartPolicy: Never` on every container.**
systemd is the single owner of the restart loop: a container exit takes the
pod down, which takes the service container down, which fails the unit,
which triggers `Restart=on-failure`. With `restartPolicy: Always` or
`OnFailure`, podman retries internally and systemd never sees the failure.
**Future direction:** see `roadmap/2026-05-29-quadlet.md` for the planned migration to
native Podman Quadlet units (`.kube` files), which removes most of this
boilerplate.
**Critical differences from system services:**
- `WantedBy=default.target` (NOT `multi-user.target`)
- No `network-online.target` dependency (doesn't exist in user systemd)
- User services start after the system is up, so network dependencies are implicit
2. **Service File Placement:**
```yaml
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Deploy systemd service
ansible.builtin.template:
src: myservice.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/myservice.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
```
**Why getent + set_fact:** `ansible_env.HOME` is evaluated in the controller/initial context and may resolve to `/root` instead of the target user's home. Using `getent` to query `/etc/passwd` provides the correct home directory (field 4), and storing it in a fact makes subsequent references cleaner and more readable.
3. **Enable Lingering:**
```yaml
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
```
Lingering ensures the user's systemd instance starts at boot and persists when the user is not logged in.
4. **Service Management:**
```yaml
- name: Enable and start service (user scope)
ansible.builtin.command: "systemctl --user enable --now myservice.service"
become_user: "{{ ansible_user }}"
```
**Why `podman play kube`:** Kubernetes Pod manifests are portable and let multiple containers share a network namespace without defining a Compose network. The `--network=pasta:...` CLI flag overrides the default bridge (which rootless users cannot create) and inherits the same loopback mapping configured in `containers.conf`.
### 4. Password Validation Pattern (REQUIRED)
All roles requiring passwords **must** validate them at the start of tasks:
```yaml
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- myservice_password is defined
- myservice_password | length >= 12
fail_msg: |
myservice_password is required (min 12 chars).
See roles/myservice/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
```
**Why this pattern:**
- Prevents accidental deployment with missing/weak passwords
- Fails fast with clear error message
- Directs users to defaults/main.yml for setup instructions
- Keeps error messages concise (target audience: minimal tech knowledge)
**In defaults/main.yml, passwords should be undefined:**
```yaml
# myservice_password: "" # Intentionally undefined - role will fail if not set
```
**Never use "changeme" defaults** - always fail if password not explicitly set.
## Best Practices
### Ansible Usage
**`become: true` is redundant** - All playbooks are run with `--ask-become-pass`, so every task already runs with elevated privileges. Only use `become_user` to switch to a specific user (e.g., `become_user: postgres`).
### Security
1. **Passwords**: Always use Ansible Vault in production
```bash
ansible-vault encrypt_string 'password' --name 'myservice_db_password'
```
2. **Bind addresses**: PostgreSQL and Redis bind to `127.0.0.1` only
3. **Database users**: Minimal privileges (NOSUPERUSER, NOCREATEDB, NOCREATEROLE)
4. **Nginx**: Only expose services that need external access
### Variable Naming
- Role-specific: `<role>_variable_name`
- Generic (cross-role): Add to `.ansible-lint` skip list
### File Permissions
- Config files: `0644`
- Secrets: `0640` or `0600`
- Directories: `0755`
- Data directories: `0750`
### Idempotency
- Use `creates:` with command/shell
- Use `changed_when: false` for read-only operations
- Use appropriate `when:` conditions
### Handlers
- Use `notify` instead of direct state changes
- Keep in `handlers/main.yml`
- Common: `Reload nginx`, `Restart PostgreSQL`, `Reload systemd`
+56 -8
View File
@@ -18,6 +18,7 @@ This is a good playground to learn and I encourage you to adapt these roles to y
- Rootless Podman: Containers run as `{{ ansible_user }}` (daemonless, `sudo podman ps` shows nothing) - Rootless Podman: Containers run as `{{ ansible_user }}` (daemonless, `sudo podman ps` shows nothing)
- User systemd services: `systemctl --user status <service>` with lingering enabled - User systemd services: `systemctl --user status <service>` with lingering enabled
- Nginx reverse proxy for web services - Nginx reverse proxy for web services
- IP Freebind when available (e.g. unbound does not wait for wireguard to be up to start resolving DNS)
**Available Services:** **Available Services:**
@@ -33,8 +34,24 @@ This is a good playground to learn and I encourage you to adapt these roles to y
| static-web | Static website hosting | | static-web | Static website hosting |
| vpn | WireGuard server | | vpn | WireGuard server |
## Port Reservation Rules
Reserved ports that **must not** be used as role defaults:
| Port(s) | Protocol | Reserved for |
| --- | --- | --- |
| 80 | tcp | Nginx |
| 443 | tcp | Nginx |
| 3000-3009 | tcp | Testing |
| 4430 | tcp | Testing |
| 8080 | tcp | Testing |
When adding a new role, pick a default port outside these ranges.
## Requirements ## Requirements
Ansible `>=2.15`
Base tools: Base tools:
```sh ```sh
@@ -60,7 +77,8 @@ If you have a password on your ssh key `--ask-pass` is recommended, `--ask-becom
```sh ```sh
ansible-playbook -i inventory/hosts.yml playbook.yml \ ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-pass \ --ask-pass \
--ask-become-pass --ask-become-pass \
--ask-vault-pass
``` ```
You can also call you ssh agent to unlock your key prior to simplify your calls: You can also call you ssh agent to unlock your key prior to simplify your calls:
@@ -72,18 +90,34 @@ ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-become-pass --ask-become-pass
``` ```
## Target configuration ## Bootstrapping a new host
Requirements: For fresh hosts (only `root` available, no admin user yet):
- sshd up and running
- public key copied:
```sh ```sh
ssh-copy-id -i ~/.ssh/id_rsa.pub username@remote_host ansible-playbook playbooks/bootstrap.yml -l <hostname> --ask-pass
``` ```
- python3 installed (`pacman -Syu python3`) This installs Python and sudo, creates `{{ ansible_user }}` with sudo rights, and copies your local `~/.ssh/id_ed25519.pub`. Supports Arch Linux and Debian/Ubuntu.
To use a different SSH key:
```sh
ansible-playbook playbooks/bootstrap.yml -l <hostname> --ask-pass \
--extra-vars 'bootstrap_ssh_public_key="ssh-ed25519 AAAA..."'
```
Then set a password for the new user (required for sudo `--ask-become-pass`):
```sh
ssh root@<hostname> passwd jambon
```
After that, run the host playbook normally:
```sh
ansible-playbook playbooks/<hostname>.yml --ask-become-pass
```
## Developping ## Developping
@@ -93,3 +127,17 @@ Linting:
ansible-lint ansible-lint
npx prettier --write . npx prettier --write .
``` ```
## Q&A
### Immich crash loop: `PostgresError: must be owner of extension vector`
Immich tries to self-update the `pgvector` extension at startup, but its database user is intentionally `NOSUPERUSER`, so the `ALTER EXTENSION vector UPDATE` call fails and the microservices worker exits with code 1.
Fix it on the running host by updating the extension as the `postgres` superuser:
```sh
sudo -u postgres psql -d immich -c 'ALTER EXTENSION vector UPDATE;'
```
The Immich role also runs this automatically on subsequent playbook runs, so re-deployments after a pgvector package upgrade do not require manual intervention.
@@ -14,10 +14,36 @@ network_interfaces:
- name: lan1 - name: lan1
type: ethernet type: ethernet
mac_address: 0a:3f:5b:1c:d2:e4 mac_address: 0a:3f:5b:1c:d2:e4
- name: podman-gw
type: bridge # Unbound DNS resolver configuration
ipv4: # ----------------------------------
address: "{{ podman_gw_gateway }}/10" unbound_custom_lan_domain: "example.lan"
unbound_interfaces:
- { address: "192.168.1.2", comment: "lan0" }
- { address: "192.168.20.4", comment: "wg0" }
unbound_access_control:
- { subnet: "192.168.1.0/24", action: "allow", view: "lan", comment: "lan0" }
- { subnet: "192.168.20.0/27", action: "allow", view: "vpn", comment: "wg0" }
unbound_custom_lan_config_path: "{{ unbound_config_base_path }}/lan.conf"
unbound_custom_lan_records:
"server.example.lan":
v4: 192.168.1.2
aliases:
- "server"
# unbound VPN configuration
unbound_custom_vpn_config_path: "{{ unbound_config_base_path }}/vpn.conf"
unbound_custom_vpn_records:
"server.example.lan":
v4: 192.168.20.4
aliases:
- "server"
unbound_firewall_allowed_sources:
- { src: "192.168.1.0/24", comment: "DNS from LAN" }
- { src: "192.168.20.0/27", comment: "DNS from VPN" }
# NTP servers configuration # NTP servers configuration
# ------------------------- # -------------------------
@@ -84,17 +110,19 @@ zfs_datasets:
mountpoint: /mnt/omer/movies mountpoint: /mnt/omer/movies
state: present state: present
# Wireguard "client" VPN configuration # Wireguard VPN configuration
# ------------------------------------ # ----------------------------
wireguard_address: 192.168.20.4/27 wireguard_tunnels:
wireguard_peers: - interface: wg0
address: 192.168.20.4/27
dns: 192.168.20.1
server_mode: false
peers:
- name: "Marge server" - name: "Marge server"
public_key: fB6zC8oWpQxN4yR2sT1uA7vJ9kH3mG5eD0cLlI8bV6aF2dP3eXwZ1qY4rU7tO9 public_key: fB6zC8oWpQxN4yR2sT1uA7vJ9kH3mG5eD0cLlI8bV6aF2dP3eXwZ1qY4rU7tO9
allowed_ips: allowed_ips:
- 192.168.20.1/32 - 192.168.20.1/32
endpoint: 192.168.1.56:51820 endpoint: 192.168.1.56:51820
wireguard_dns: 192.168.20.1
wireguard_server_mode: false
# NFS server configuration # NFS server configuration
# ------------------------ # ------------------------
@@ -119,24 +147,24 @@ nfs_bind_addresses:
# Podman configuration # Podman configuration
# -------------------- # --------------------
podman_gw_gateway: 100.64.0.1 # Address inside containers that maps to the host's loopback (via pasta
podman_gw_subnet: 100.64.0.0/10 # --map-host-loopback). Containers reach host services bound to 127.0.0.1
# by connecting to this address. Defined in roles/podman/defaults/main.yml.
# podman_gw_gateway: 100.64.0.1
# PostgreSQL configuration # PostgreSQL configuration
# ------------------------ # ------------------------
postgres_admin_password: "{{ vault_postgres_admin_password }}" postgres_admin_password: "{{ vault_postgres_admin_password }}"
postgres_bind: "127.0.0.1,{{ podman_gw_gateway }}" # Comma-separated for PostgreSQL postgres_bind: "127.0.0.1"
postgres_firewall_allowed_sources: postgres_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
- "{{ podman_gw_subnet }}"
# Valkey configuration # Valkey configuration
# -------------------- # --------------------
valkey_admin_password: "{{ vault_valkey_admin_password }}" valkey_admin_password: "{{ vault_valkey_admin_password }}"
valkey_bind: "127.0.0.1 {{ podman_gw_gateway }}" # Space-separated for Valkey valkey_bind: "127.0.0.1"
valkey_firewall_allowed_sources: valkey_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
- "{{ podman_gw_subnet }}"
# Valkey ACL users # Valkey ACL users
valkey_acl_users: valkey_acl_users:
+73
View File
@@ -0,0 +1,73 @@
---
# Bootstrap a fresh host: create the admin user with sudo and SSH access.
# Run this before any other playbook, when only root access is available:
#
# ansible-playbook playbooks/bootstrap.yml -l somehost
#
# After this, run other playbooks normally.
- name: Bootstrap admin user
hosts: "{{ target | default('all') }}"
gather_facts: false
vars:
ansible_user: root
ansible_become: false
# bootstrap_user: jambon
# bootstrap_ssh_public_key: "ssh-ed25519 AAAA..."
tasks:
- name: Detect OS and install python3 + sudo
ansible.builtin.raw: |
if command -v pacman > /dev/null 2>&1; then
pacman -Sy --noconfirm python sudo
elif command -v apt-get > /dev/null 2>&1; then
apt-get update -qq && apt-get install -y python3 sudo
else
echo "Unsupported OS" && exit 1
fi
changed_when: true
- name: Gather facts
ansible.builtin.setup:
- name: Create admin user
ansible.builtin.user:
name: "{{ bootstrap_user }}"
groups: "{{ 'wheel' if ansible_facts['os_family'] == 'Archlinux' else 'sudo' }}"
append: true
shell: /bin/bash
create_home: true
state: present
- name: Allow sudo group to use sudo (Debian)
ansible.builtin.copy:
content: "%sudo ALL=(ALL:ALL) ALL\n"
dest: /etc/sudoers.d/sudo
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
when: ansible_facts['os_family'] == 'Debian'
- name: Allow wheel group to use sudo (Arch)
ansible.builtin.copy:
content: "%wheel ALL=(ALL:ALL) ALL\n"
dest: /etc/sudoers.d/wheel
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
when: ansible_facts['os_family'] == 'Archlinux'
- name: Create .ssh directory
ansible.builtin.file:
path: "/home/{{ bootstrap_user }}/.ssh"
state: directory
owner: "{{ bootstrap_user }}"
group: "{{ bootstrap_user }}"
mode: "0700"
- name: Add SSH authorized key
ansible.posix.authorized_key:
user: "{{ bootstrap_user }}"
key: "{{ bootstrap_ssh_public_key | default(lookup('file', '~/.ssh/id_ed25519.pub')) }}"
state: present
@@ -1,7 +1,7 @@
--- ---
- hosts: marge - name: Sample of a playbook
hosts: marge
become: true become: true
roles: roles:
- role: ntpd
- role: fail2ban - role: fail2ban
- role: unbound - role: unbound
+2
View File
@@ -0,0 +1,2 @@
---
requires_ansible: ">=2.15"
+2
View File
@@ -1,6 +1,8 @@
--- ---
collections: collections:
- name: ansible.netcommon - name: ansible.netcommon
- name: ansible.posix
version: ">=2.2.0"
- name: community.general - name: community.general
- name: community.postgresql - name: community.postgresql
- name: containers.podman - name: containers.podman
+9 -3
View File
@@ -1,15 +1,21 @@
--- ---
- name: Configure locales - name: Configure locales
block: block:
- name: Activate locale
ansible.builtin.command:
cmd: localectl set-locale LANG={{ arch_locale }}
- name: Edit /etc/locale.gen - name: Edit /etc/locale.gen
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
dest: /etc/locale.gen dest: /etc/locale.gen
state: present state: present
regexp: "{{ arch_locale }}" regexp: "{{ arch_locale }}"
line: "{{ arch_locale }} UTF-8" line: "{{ arch_locale }} UTF-8"
register: locale_gen_changed
- name: Regenerate locales - name: Regenerate locales
ansible.builtin.command: ansible.builtin.command:
cmd: locale-gen cmd: locale-gen
when: locale_gen_changed is changed
changed_when: true
- name: Activate locale
ansible.builtin.command:
cmd: localectl set-locale LANG={{ arch_locale }}
changed_when: false
+4
View File
@@ -3,6 +3,10 @@
ansible.builtin.meta: end_play ansible.builtin.meta: end_play
when: ansible_facts['os_family'] != 'Archlinux' when: ansible_facts['os_family'] != 'Archlinux'
- name: Set hostname
ansible.builtin.hostname:
name: "{{ inventory_hostname }}"
- name: Archlinux base setup - name: Archlinux base setup
ansible.builtin.include_tasks: "{{ item }}" ansible.builtin.include_tasks: "{{ item }}"
loop: loop:
+12 -5
View File
@@ -68,7 +68,7 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
paru_url: "{{ item.browser_download_url }}" paru_url: "{{ item.browser_download_url }}"
loop: "{{ paru_release.json.assets }}" loop: "{{ paru_release.json.assets }}"
when: "'os_arch.tar.zst' in item.name" when: "(os_arch + '.tar.zst') in item.name"
- name: Download - name: Download
ansible.builtin.get_url: ansible.builtin.get_url:
@@ -77,12 +77,19 @@
mode: "0644" mode: "0644"
- name: Extract paru - name: Extract paru
ansible.builtin.command: ansible.builtin.unarchive:
cmd: "tar -xf /tmp/paru-{{ os_arch }}.tar.zst paru -C /tmp" src: "/tmp/paru-{{ os_arch }}.tar.zst"
dest: /tmp
remote_src: true
extra_opts:
- paru
- name: Install paru binary - name: Install paru binary
ansible.builtin.command: ansible.builtin.copy:
cmd: "mv /tmp/paru /usr/bin/paru" src: /tmp/paru
dest: /usr/bin/paru
remote_src: true
mode: "0755"
- name: Ensure permissions - name: Ensure permissions
ansible.builtin.file: ansible.builtin.file:
+8 -2
View File
@@ -22,21 +22,27 @@
line: "%wheel ALL=(ALL) NOPASSWD: ALL" line: "%wheel ALL=(ALL) NOPASSWD: ALL"
validate: /usr/sbin/visudo -cf %s validate: /usr/sbin/visudo -cf %s
- name: Clean up stale yay sources dir
ansible.builtin.file:
path: "{{ yay_src_path }}"
state: absent
- name: Create yay sources dir - name: Create yay sources dir
ansible.builtin.file: ansible.builtin.file:
path: "{{ yay_src_path }}" path: "{{ yay_src_path }}"
state: directory state: directory
owner: "{{ ansible_user }}" owner: "{{ ansible_user }}"
mode: "0755"
- name: Clone git sources - name: Clone git sources
become: false become_user: "{{ ansible_user }}"
ansible.builtin.git: ansible.builtin.git:
repo: "{{ yay_git_repo }}" repo: "{{ yay_git_repo }}"
dest: "{{ yay_src_path }}" dest: "{{ yay_src_path }}"
# note: this only works because SUDOERS password prompt is disabled # note: this only works because SUDOERS password prompt is disabled
- name: Build and install - name: Build and install
become: false become_user: "{{ ansible_user }}"
ansible.builtin.command: ansible.builtin.command:
chdir: "{{ yay_src_path }}" chdir: "{{ yay_src_path }}"
cmd: "makepkg -si -f --noconfirm" cmd: "makepkg -si -f --noconfirm"
+29
View File
@@ -0,0 +1,29 @@
# dhcpd
ISC DHCP server role for Arch Linux and Debian/Ubuntu.
## Requirements
- `dhcpd_interface` must be defined in inventory
## Configuration
See [defaults/main.yml](defaults/main.yml) for all available variables.
## Example
```yaml
dhcpd_interface: "lan0"
dhcpd_subnet: "192.168.1.0"
dhcpd_range_start: "192.168.1.20"
dhcpd_range_end: "192.168.1.200"
dhcpd_gateway: "192.168.1.1"
dhcpd_dns_servers:
- "192.168.1.2"
dhcpd_domain_name: "home.lan"
dhcpd_reservations:
- hostname: printer
mac: "aa:bb:cc:dd:ee:ff"
ip: "192.168.1.10"
```
+27
View File
@@ -0,0 +1,27 @@
# Network configuration
dhcpd_subnet: "192.168.1.0"
dhcpd_netmask: "255.255.255.0"
dhcpd_range_start: "192.168.1.20"
dhcpd_range_end: "192.168.1.200"
dhcpd_gateway: "192.168.1.1"
dhcpd_dns_servers:
- "1.1.1.1"
# Lease times (in seconds)
dhcpd_default_lease_time: 86400 # 24 hours
dhcpd_max_lease_time: 172800 # 48 hours
# Interface to listen on (required)
# dhcpd_interface: "lan0"
# Domain name (optional)
# dhcpd_domain_name: "home.lan"
# Static reservations
# dhcpd_reservations:
# - hostname: printer
# mac: "aa:bb:cc:dd:ee:ff"
# ip: "192.168.1.10"
# - hostname: nas
# mac: "11:22:33:44:55:66"
# ip: "192.168.1.2"
+9
View File
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart dhcpd
ansible.builtin.systemd:
name: "{{ dhcpd_service }}"
state: restarted
+81
View File
@@ -0,0 +1,81 @@
---
- name: Validate required variables
ansible.builtin.assert:
that:
- dhcpd_interface is defined
- dhcpd_interface | length > 0
fail_msg: |
dhcpd_interface is required.
See roles/dhcpd/defaults/main.yml for configuration instructions.
success_msg: "Variable validation passed"
- name: Load OS-specific variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['os_family'] | lower }}.yml"
- "debian.yml"
- name: Install DHCP server
ansible.builtin.package:
name: "{{ dhcpd_package }}"
state: present
- name: Deploy DHCP server configuration
ansible.builtin.template:
src: dhcpd.conf.j2
dest: "{{ dhcpd_config_path }}"
owner: root
group: root
mode: "0644"
notify: Restart dhcpd
- name: Configure interface for DHCP server (Debian)
ansible.builtin.template:
src: isc-dhcp-server.j2
dest: "{{ dhcpd_defaults_path }}"
owner: root
group: root
mode: "0644"
when: ansible_facts['os_family'] | lower == 'debian'
notify: Restart dhcpd
- name: Deploy dhcpd4@ systemd template unit (Arch)
ansible.builtin.template:
src: dhcpd4@.service.j2
dest: /usr/lib/systemd/system/dhcpd4@.service
owner: root
group: root
mode: "0644"
when: ansible_facts['os_family'] == 'Archlinux'
notify:
- Reload systemd
- Restart dhcpd
- name: Disable generic dhcpd4.service (Arch)
ansible.builtin.systemd:
name: "{{ dhcpd_service_generic }}"
enabled: false
state: stopped
when:
- ansible_facts['os_family'] == 'Archlinux'
- dhcpd_service_generic is defined
failed_when: false
- name: Enable and start DHCP server
ansible.builtin.systemd:
name: "{{ dhcpd_service }}"
enabled: true
state: started
- name: Allow DHCP traffic on {{ dhcpd_interface }}
community.general.ufw:
rule: allow
port: "67"
proto: udp
direction: in
interface: "{{ dhcpd_interface }}"
comment: "DHCP on {{ dhcpd_interface }}"
retries: 5
delay: 2
register: ufw_dhcp_result
until: ufw_dhcp_result is succeeded
+28
View File
@@ -0,0 +1,28 @@
# {{ ansible_managed }}
# Global options
default-lease-time {{ dhcpd_default_lease_time }};
max-lease-time {{ dhcpd_max_lease_time }};
authoritative;
{% if dhcpd_domain_name is defined %}
option domain-name "{{ dhcpd_domain_name }}";
{% endif %}
option domain-name-servers {{ dhcpd_dns_servers | join(', ') }};
# Subnet configuration
subnet {{ dhcpd_subnet }} netmask {{ dhcpd_netmask }} {
range {{ dhcpd_range_start }} {{ dhcpd_range_end }};
option routers {{ dhcpd_gateway }};
}
# Static reservations
{% if dhcpd_reservations is defined %}
{% for host in dhcpd_reservations %}
host {{ host.hostname }} {
hardware ethernet {{ host.mac }};
fixed-address {{ host.ip }};
}
{% endfor %}
{% endif %}
+16
View File
@@ -0,0 +1,16 @@
# {{ ansible_managed }}
[Unit]
Description=IPv4 DHCP server on %I
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]
Type=forking
ExecStart=/usr/bin/dhcpd -4 -q -cf /etc/dhcpd.conf -pf /run/dhcpd4/dhcpd-%i.pid %I
RuntimeDirectory=dhcpd4
PIDFile=/run/dhcpd4/dhcpd-%i.pid
[Install]
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
# {{ ansible_managed }}
# Defaults for isc-dhcp-server
INTERFACESv4="{{ dhcpd_interface }}"
INTERFACESv6=""
+4
View File
@@ -0,0 +1,4 @@
dhcpd_package: dhcp
dhcpd_service: "dhcpd4@{{ dhcpd_interface }}"
dhcpd_service_generic: dhcpd4
dhcpd_config_path: /etc/dhcpd.conf
+4
View File
@@ -0,0 +1,4 @@
dhcpd_package: isc-dhcp-server
dhcpd_service: isc-dhcp-server
dhcpd_config_path: /etc/dhcp/dhcpd.conf
dhcpd_defaults_path: /etc/default/isc-dhcp-server
+4
View File
@@ -0,0 +1,4 @@
---
- name: Systemd daemon reload
ansible.builtin.systemd:
daemon_reload: true
+1 -5
View File
@@ -23,11 +23,7 @@
group: root group: root
mode: "0644" mode: "0644"
register: timer_config register: timer_config
notify: Systemd daemon reload
- name: Systemd daemon reload
ansible.builtin.systemd:
daemon_reload: true
when: timer_config.changed
- name: Enable periodic trim - name: Enable periodic trim
ansible.builtin.systemd: ansible.builtin.systemd:
+4
View File
@@ -0,0 +1,4 @@
---
- name: Inform user to relogin
ansible.builtin.debug:
msg: "Please logout and login again to make sure the user is added to the docker group"
+1 -6
View File
@@ -35,9 +35,4 @@
name: "{{ ansible_user }}" name: "{{ ansible_user }}"
groups: docker groups: docker
append: true append: true
register: docker_group notify: Inform user to relogin
- name: Inform the user that user needs to logout and login again
ansible.builtin.debug:
msg: "Please logout and login again to make sure the user is added to the docker group"
when: docker_group.changed
+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
+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;
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
--- ---
# Gitea version # Gitea version
gitea_version: "1.22" gitea_version: "latest"
# Network configuration # Network configuration
gitea_port: 3000 gitea_port: 3100
# Container image # Container image
gitea_image: gitea/gitea gitea_image: gitea/gitea
+9 -4
View File
@@ -4,13 +4,18 @@
daemon_reload: true daemon_reload: true
- name: Reload systemd user - name: Reload systemd user
ansible.builtin.command: "systemctl --user daemon-reload" ansible.builtin.systemd:
become: true daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Restart gitea - name: Restart gitea
ansible.builtin.command: "systemctl --user restart gitea.service" ansible.builtin.systemd:
become: true name: gitea.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Reload nginx - name: Reload nginx
+26 -2
View File
@@ -14,6 +14,7 @@
name: "{{ gitea_postgres_user }}" name: "{{ gitea_postgres_user }}"
password: "{{ gitea_postgres_password }}" password: "{{ gitea_postgres_password }}"
state: present state: present
become: false
become_user: "{{ postgres_admin_user }}" become_user: "{{ postgres_admin_user }}"
- name: Create PostgreSQL database for Gitea - name: Create PostgreSQL database for Gitea
@@ -21,6 +22,7 @@
name: "{{ gitea_postgres_db_name }}" name: "{{ gitea_postgres_db_name }}"
owner: "{{ gitea_postgres_user }}" owner: "{{ gitea_postgres_user }}"
state: present state: present
become: false
become_user: "{{ postgres_admin_user }}" become_user: "{{ postgres_admin_user }}"
- name: Grant all privileges on database to Gitea user - name: Grant all privileges on database to Gitea user
@@ -30,6 +32,7 @@
type: database type: database
privs: ALL privs: ALL
state: present state: present
become: false
become_user: "{{ postgres_admin_user }}" become_user: "{{ postgres_admin_user }}"
- name: Ensure Gitea user has no superuser privileges - name: Ensure Gitea user has no superuser privileges
@@ -37,6 +40,7 @@
name: "{{ gitea_postgres_user }}" name: "{{ gitea_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL schema for Gitea - name: Create PostgreSQL schema for Gitea
@@ -45,6 +49,7 @@
database: "{{ gitea_postgres_db_name }}" database: "{{ gitea_postgres_db_name }}"
owner: "{{ gitea_postgres_user }}" owner: "{{ gitea_postgres_user }}"
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant schema permissions to Gitea user - name: Grant schema permissions to Gitea user
@@ -55,6 +60,7 @@
objs: "{{ gitea_postgres_schema }}" objs: "{{ gitea_postgres_schema }}"
privs: CREATE,USAGE privs: CREATE,USAGE
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Gitea project directory - name: Create Gitea project directory
@@ -73,6 +79,13 @@
group: "{{ ansible_user }}" group: "{{ ansible_user }}"
mode: "0755" mode: "0755"
- name: Pull Gitea container image
ansible.builtin.command: "podman pull {{ gitea_image }}:{{ gitea_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Gitea - name: Deploy Kubernetes YAML for Gitea
ansible.builtin.template: ansible.builtin.template:
src: gitea.yaml.j2 src: gitea.yaml.j2
@@ -89,7 +102,7 @@
- name: Set user home directory fact - name: Set user home directory fact
ansible.builtin.set_fact: ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}" user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Gitea - name: Create systemd user directory for Gitea
ansible.builtin.file: ansible.builtin.file:
@@ -113,9 +126,20 @@
when: ansible_user != 'root' when: ansible_user != 'root'
- name: Enable and start Gitea service (user scope) - name: Enable and start Gitea service (user scope)
ansible.builtin.command: "systemctl --user enable --now gitea.service" ansible.builtin.systemd:
name: gitea.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Gitea
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ gitea_nginx_hostname }}"
when: gitea_nginx_enabled
- name: Deploy nginx vhost configuration for Gitea - name: Deploy nginx vhost configuration for Gitea
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Gitea Git Service Description=Gitea Git Service
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea
ExecStart=/usr/bin/podman play kube --replace gitea.yaml ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} gitea.yaml
ExecStop=/usr/bin/podman play kube --down gitea.yaml ExecStop=/usr/bin/podman kube down gitea.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+1 -1
View File
@@ -41,7 +41,7 @@ spec:
readOnly: true readOnly: true
- name: gitea-data - name: gitea-data
mountPath: /data mountPath: /data
restartPolicy: Always restartPolicy: Never
volumes: volumes:
- name: localtime - name: localtime
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ gitea_nginx_hostname }}; server_name {{ gitea_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
server_name {{ gitea_nginx_hostname }}; server_name {{ gitea_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # Let's Encrypt certificates (managed by Certbot)
+7
View File
@@ -13,6 +13,13 @@ Both passwords must be set in your inventory (min 12 characters):
- `immich_postgres_password` - PostgreSQL database password - `immich_postgres_password` - PostgreSQL database password
- `immich_valkey_password` - Valkey/Redis password - `immich_valkey_password` - Valkey/Redis password
## External Libraries
Mount host paths read-only into the server container via `immich_external_libraries`,
then add the in-container `mount_path` in the Immich UI
(Administration → External Libraries). The `{{ ansible_user }}` running the rootless
pod must have read access on the host path.
## Troubleshooting ## Troubleshooting
### Valkey ACL Issues ### Valkey ACL Issues
+11 -2
View File
@@ -5,17 +5,26 @@ immich_version: release
# Storage location (@see https://docs.immich.app/install/environment-variables/) # Storage location (@see https://docs.immich.app/install/environment-variables/)
immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload" immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload"
# External libraries (read-only host paths exposed to the server container)
# Use the in-container `mount_path` when registering the library in the Immich UI.
# Example:
# immich_external_libraries:
# - name: clement-photos
# host_path: /mnt/andromeda/clement-photos
# mount_path: /mnt/external/clement-photos
immich_external_libraries: []
# PostgreSQL configuration (REQUIRED password - must be set explicitly) # PostgreSQL configuration (REQUIRED password - must be set explicitly)
immich_postgres_db_name: immich immich_postgres_db_name: immich
immich_postgres_user: immich immich_postgres_user: immich
# immich_postgres_password: "" # Intentionally undefined - role will fail if not set # immich_postgres_password: "" # Intentionally undefined - role will fail if not set
# immich_postgres_host: "" # Must be set in inventory (e.g., podman_gw_gateway) # immich_postgres_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host postgres)
immich_postgres_port: 5432 immich_postgres_port: 5432
# Valkey configuration (REQUIRED password - must be set explicitly) # Valkey configuration (REQUIRED password - must be set explicitly)
immich_valkey_user: immich immich_valkey_user: immich
# immich_valkey_password: "" # Intentionally undefined - role will fail if not set # immich_valkey_password: "" # Intentionally undefined - role will fail if not set
# immich_valkey_host: "" # Must be set in inventory (e.g., podman_gw_gateway) # immich_valkey_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host valkey)
immich_valkey_port: 6379 immich_valkey_port: 6379
immich_valkey_db: 0 # Dedicated database number for isolation (0-15) immich_valkey_db: 0 # Dedicated database number for isolation (0-15)
+9 -4
View File
@@ -4,13 +4,18 @@
daemon_reload: true daemon_reload: true
- name: Reload systemd user - name: Reload systemd user
ansible.builtin.command: "systemctl --user daemon-reload" ansible.builtin.systemd:
become: true daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Restart Immich - name: Restart Immich
ansible.builtin.command: "systemctl --user restart immich.service" ansible.builtin.systemd:
become: true name: immich.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Reload nginx - name: Reload nginx
+41 -2
View File
@@ -16,6 +16,7 @@
name: "{{ immich_postgres_db_name }}" name: "{{ immich_postgres_db_name }}"
owner: "{{ immich_postgres_user }}" owner: "{{ immich_postgres_user }}"
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL user for Immich - name: Create PostgreSQL user for Immich
@@ -23,6 +24,7 @@
name: "{{ immich_postgres_user }}" name: "{{ immich_postgres_user }}"
password: "{{ immich_postgres_password }}" password: "{{ immich_postgres_password }}"
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant all privileges on database to Immich user - name: Grant all privileges on database to Immich user
@@ -32,6 +34,7 @@
type: database type: database
privs: ALL privs: ALL
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Ensure Immich user has no superuser privileges - name: Ensure Immich user has no superuser privileges
@@ -39,6 +42,7 @@
name: "{{ immich_postgres_user }}" name: "{{ immich_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Enable required PostgreSQL extensions in Immich database - name: Enable required PostgreSQL extensions in Immich database
@@ -46,12 +50,25 @@
name: "{{ item }}" name: "{{ item }}"
login_db: "{{ immich_postgres_db_name }}" login_db: "{{ immich_postgres_db_name }}"
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
loop: loop:
- cube - cube
- earthdistance - earthdistance
- vector - vector
- name: Update PostgreSQL extensions to latest available version
community.postgresql.postgresql_query:
login_db: "{{ immich_postgres_db_name }}"
query: "ALTER EXTENSION {{ item }} UPDATE"
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
loop:
- cube
- earthdistance
- vector
changed_when: false
- name: Grant schema permissions to Immich user - name: Grant schema permissions to Immich user
community.postgresql.postgresql_privs: community.postgresql.postgresql_privs:
login_db: "{{ immich_postgres_db_name }}" login_db: "{{ immich_postgres_db_name }}"
@@ -60,6 +77,7 @@
objs: public objs: public
privs: CREATE,USAGE privs: CREATE,USAGE
state: present state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}" become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Immich project directory - name: Create Immich project directory
@@ -80,6 +98,16 @@
loop: loop:
- "{{ immich_upload_location }}" - "{{ immich_upload_location }}"
- name: Pull Immich container images
ansible.builtin.command: "podman pull {{ item }}"
loop:
- "{{ immich_server_image }}:{{ immich_version }}"
- "{{ immich_ml_image }}:{{ immich_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Immich - name: Deploy Kubernetes YAML for Immich
ansible.builtin.template: ansible.builtin.template:
src: immich.yaml.j2 src: immich.yaml.j2
@@ -96,7 +124,7 @@
- name: Set user home directory fact - name: Set user home directory fact
ansible.builtin.set_fact: ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}" user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Immich - name: Create systemd user directory for Immich
ansible.builtin.file: ansible.builtin.file:
@@ -120,9 +148,20 @@
when: ansible_user != 'root' when: ansible_user != 'root'
- name: Enable and start Immich service (user scope) - name: Enable and start Immich service (user scope)
ansible.builtin.command: "systemctl --user enable --now immich.service" ansible.builtin.systemd:
name: immich.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Immich
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ immich_nginx_hostname }}"
when: immich_nginx_enabled
- name: Deploy nginx vhost configuration for Immich - name: Deploy nginx vhost configuration for Immich
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Immich Media Server Description=Immich Media Server
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich
ExecStart=/usr/bin/podman play kube --replace immich.yaml ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} immich.yaml
ExecStop=/usr/bin/podman play kube --down immich.yaml ExecStop=/usr/bin/podman kube down immich.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+13 -5
View File
@@ -5,9 +5,6 @@ metadata:
name: immich name: immich
labels: labels:
app: immich app: immich
annotations:
io.podman.annotations.network.mode: bridge
io.podman.annotations.network.name: podman-gw
spec: spec:
containers: containers:
- name: server - name: server
@@ -48,6 +45,11 @@ spec:
readOnly: true readOnly: true
- name: immich-data - name: immich-data
mountPath: /data mountPath: /data
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
mountPath: {{ lib.mount_path }}
readOnly: true
{% endfor %}
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/server/ping path: /api/server/ping
@@ -56,7 +58,7 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
failureThreshold: 3 failureThreshold: 3
restartPolicy: Always restartPolicy: Never
- name: machine-learning - name: machine-learning
image: {{ immich_ml_image }}:{{ immich_version }} image: {{ immich_ml_image }}:{{ immich_version }}
@@ -75,7 +77,7 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
failureThreshold: 3 failureThreshold: 3
restartPolicy: Always restartPolicy: Never
volumes: volumes:
- name: localtime - name: localtime
@@ -86,6 +88,12 @@ spec:
hostPath: hostPath:
path: {{ immich_upload_location }} path: {{ immich_upload_location }}
type: Directory type: Directory
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
hostPath:
path: {{ lib.host_path }}
type: Directory
{% endfor %}
- name: model-cache - name: model-cache
persistentVolumeClaim: persistentVolumeClaim:
claimName: immich-model-cache claimName: immich-model-cache
+15 -1
View File
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ immich_nginx_hostname }}; server_name {{ immich_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,8 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ immich_nginx_hostname }}; server_name {{ immich_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # Let's Encrypt certificates (managed by Certbot)
@@ -38,6 +41,12 @@ server {
client_max_body_size 50000M; client_max_body_size 50000M;
# Timeouts for slow mobile uploads (client <-> nginx leg)
client_body_timeout 600s;
client_header_timeout 600s;
send_timeout 600s;
keepalive_timeout 600s;
location / { location / {
proxy_pass http://127.0.0.1:{{ immich_port }}; proxy_pass http://127.0.0.1:{{ immich_port }};
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
@@ -50,7 +59,12 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
# Timeouts for large file uploads # Stream uploads directly to backend instead of buffering full body on disk
proxy_request_buffering off;
proxy_buffering off;
# Timeouts for large file uploads (nginx <-> immich leg)
proxy_connect_timeout 600s;
proxy_read_timeout 600s; proxy_read_timeout 600s;
proxy_send_timeout 600s; proxy_send_timeout 600s;
} }
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Matrix Synapse + Element Web
Wants=network-online.target
After=network-online.target
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/matrix
ExecStart=/usr/bin/podman kube play --replace --service-container=true matrix.yaml
ExecStop=/usr/bin/podman kube down matrix.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+63
View File
@@ -0,0 +1,63 @@
---
apiVersion: v1
kind: Pod
metadata:
name: matrix
labels:
app: matrix
spec:
containers:
- name: synapse
image: {{ synapse_image }}:{{ synapse_version }}
ports:
- containerPort: 8008
hostPort: {{ synapse_port }}
{% if synapse_enable_federation %}
- containerPort: 8448
hostPort: {{ synapse_federation_port }}
{% endif %}
env:
- name: SYNAPSE_CONFIG_PATH
value: /data/homeserver.yaml
- name: TZ
value: "{{ matrix_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: synapse-data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
- name: element
image: {{ element_image }}:{{ element_version }}
ports:
- containerPort: 80
hostPort: 8080
volumeMounts:
- name: element-config
mountPath: /app/config.json
subPath: config.json
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: synapse-data
hostPath:
path: {{ synapse_data_dir }}
type: Directory
- name: element-config
hostPath:
path: {{ element_data_dir }}
type: Directory
+20
View File
@@ -0,0 +1,20 @@
# Metabase
Business intelligence and analytics. Defaults: [`defaults/main.yml`](defaults/main.yml).
## Requirements
- `podman` role
- `postgres` role
- `nginx` role (optional, for public access)
## Usage
Set in inventory:
```yaml
metabase_postgres_password: "strongpassword"
metabase_postgres_host: "{{ podman_gw_gateway }}"
metabase_nginx_enabled: true
metabase_nginx_hostname: metabase.example.com
```
+16
View File
@@ -0,0 +1,16 @@
---
metabase_version: latest
metabase_image: metabase/metabase
metabase_port: 3000
metabase_postgres_db_name: metabase
metabase_postgres_user: metabase
# metabase_postgres_password: "" # Intentionally undefined - role will fail if not set
# metabase_postgres_host: "" # Must be set in inventory (e.g. "{{ podman_gw_gateway }}")
metabase_postgres_port: 5432
metabase_timezone: UTC
metabase_nginx_enabled: false
metabase_nginx_hostname: metabase.nas.local
+20
View File
@@ -0,0 +1,20 @@
---
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart Metabase
ansible.builtin.systemd:
name: metabase.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+4
View File
@@ -0,0 +1,4 @@
---
dependencies:
- role: podman
- role: postgres
+130
View File
@@ -0,0 +1,130 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- metabase_postgres_password is defined
- metabase_postgres_password | length >= 12
fail_msg: |
metabase_postgres_password is required (min 12 chars).
See roles/metabase/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create PostgreSQL database for Metabase
community.postgresql.postgresql_db:
name: "{{ metabase_postgres_db_name }}"
owner: "{{ metabase_postgres_user }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL user for Metabase
community.postgresql.postgresql_user:
name: "{{ metabase_postgres_user }}"
password: "{{ metabase_postgres_password }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant all privileges on database to Metabase user
community.postgresql.postgresql_privs:
login_db: "{{ metabase_postgres_db_name }}"
roles: "{{ metabase_postgres_user }}"
type: database
privs: ALL
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Ensure Metabase user has no superuser privileges
community.postgresql.postgresql_user:
name: "{{ metabase_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Metabase project directory
ansible.builtin.file:
path: "{{ podman_projects_dir }}/metabase"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Pull Metabase container image
ansible.builtin.command: "podman pull {{ metabase_image }}:{{ metabase_version }}"
register: pull_result
changed_when: pull_result.stdout is search('Writing manifest')
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Metabase
ansible.builtin.template:
src: metabase.yaml.j2
dest: "{{ podman_projects_dir }}/metabase/metabase.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart Metabase
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Metabase
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Deploy systemd service for Metabase (user scope)
ansible.builtin.template:
src: metabase.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/metabase.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
- name: Enable and start Metabase service (user scope)
ansible.builtin.systemd:
name: metabase.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Metabase
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ metabase_nginx_hostname }}"
when: metabase_nginx_enabled
- name: Deploy nginx vhost configuration for Metabase
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/metabase.conf"
owner: root
group: root
mode: "0644"
when: metabase_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for Metabase
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/metabase.conf"
state: absent
when: not metabase_nginx_enabled
notify: Reload nginx
@@ -0,0 +1,15 @@
[Unit]
Description=Metabase BI Server
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/metabase
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} metabase.yaml
ExecStop=/usr/bin/podman kube down metabase.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+42
View File
@@ -0,0 +1,42 @@
apiVersion: v1
kind: Pod
metadata:
name: metabase
spec:
containers:
- name: server
image: {{ metabase_image }}:{{ metabase_version }}
ports:
- containerPort: 3000
hostPort: {{ metabase_port }}
env:
- name: MB_DB_TYPE
value: postgres
- name: MB_DB_DBNAME
value: "{{ metabase_postgres_db_name }}"
- name: MB_DB_PORT
value: "{{ metabase_postgres_port }}"
- name: MB_DB_USER
value: "{{ metabase_postgres_user }}"
- name: MB_DB_PASS
value: "{{ metabase_postgres_password }}"
- name: MB_DB_HOST
value: "{{ metabase_postgres_host }}"
- name: JAVA_TIMEZONE
value: "{{ metabase_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 90
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
volumes:
- name: localtime
hostPath: { path: /etc/localtime, type: File }
@@ -0,0 +1,49 @@
# Metabase vhost
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
listen [::]:80;
server_name {{ metabase_nginx_hostname }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ metabase_nginx_hostname }};
ssl_certificate /etc/letsencrypt/live/{{ metabase_nginx_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ metabase_nginx_hostname }}/privkey.pem;
ssl_protocols {{ nginx_ssl_protocols | default('TLSv1.3') }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend | default('journald') == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_metabase;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_metabase;
{% else %}
access_log /var/log/nginx/{{ metabase_nginx_hostname }}_access.log main;
error_log /var/log/nginx/{{ metabase_nginx_hostname }}_error.log;
{% endif %}
location / {
proxy_pass http://127.0.0.1:{{ metabase_port }};
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
--- ---
- name: install oryx - name: Install oryx
cmd: paru -S oryx ansible.builtin.command: paru -S --noconfirm oryx
when: ansible_facts['os_family'] == 'Archlinux' when: ansible_facts['os_family'] == 'Archlinux'
changed_when: false
-34
View File
@@ -1,34 +0,0 @@
---
- name: Check if the interface ipv4 address is defined
ansible.builtin.debug:
msg: "Warning: iface {{ interface.name }} has no defined ipv4 address, skipping configuration"
when: interface.ipv4.address is not defined
- name: Process interface configuration
when: interface.ipv4.address is defined
block:
- name: Create systemd-netdev file for virtual interface
when:
- interface.type is defined
- interface.type != 'ethernet'
ansible.builtin.template:
src: systemd.netdev.j2
dest: /etc/systemd/network/10-{{ interface.name }}.netdev
owner: root
group: root
mode: "0644"
register: netdev_result
- name: Create systemd-network configuration file
ansible.builtin.template:
src: systemd.network.j2
dest: /etc/systemd/network/20-{{ interface.name }}.network
owner: root
group: root
mode: "0644"
register: network_result
- name: Notify a reload is required
ansible.builtin.set_fact:
network_reload_required: true
when: netdev_result is changed or network_result is changed
+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"
+72
View File
@@ -0,0 +1,72 @@
---
- name: Check if the interface ipv4 address is defined
ansible.builtin.debug:
msg: "Warning: iface {{ interface.name }} has no defined ipv4 address, skipping configuration"
when: interface.ipv4.address is not defined
- name: Process interface configuration
when: interface.ipv4.address is defined
block:
- name: Create systemd-netdev file for virtual interface
when:
- interface.type is defined
- interface.type != 'ethernet'
ansible.builtin.template:
src: systemd.netdev.j2
dest: /etc/systemd/network/10-{{ interface.name }}.netdev
owner: root
group: root
mode: "0644"
register: netdev_result
- name: Create systemd-network configuration file
ansible.builtin.template:
src: systemd.network.j2
dest: /etc/systemd/network/20-{{ interface.name }}.network
owner: root
group: root
mode: "0644"
register: network_result
- name: Notify a reload is required
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' %} {% if interface.type is defined and interface.type == 'bridge' %}
ConfigureWithoutCarrier=yes ConfigureWithoutCarrier=yes
{% endif %} {% endif %}
{% if interface.ipv4.forward | default(false) %}
IPForward=ipv4
{% endif %}
{% if interface.ipv4.masquerade | default(false) %}
IPMasquerade=ipv4
{% endif %}
{% if interface.ipv4.nameservers is defined %} {% if interface.ipv4.nameservers is defined %}
{% for dns in interface.ipv4.nameservers %} {% for dns in interface.ipv4.nameservers %}
DNS={{ dns }} DNS={{ dns }}
@@ -7,7 +7,7 @@
- name: Process ethernet interface persistence - name: Process ethernet interface persistence
when: interface.type is not defined or interface.type == 'ethernet' when: interface.type is not defined or interface.type == 'ethernet'
block: block:
- name: "Check {{ interface.name }} ({{ interface.mac_address }}) rule" - name: "Check interface rule for {{ interface.name }} ({{ interface.mac_address | default('N/A') }})"
ansible.builtin.set_fact: ansible.builtin.set_fact:
interface_original_name: "{{ ansible_facts.interfaces | select('in', ansible_facts) | map('extract', ansible_facts) | selectattr('pciid', 'defined') | selectattr('macaddress', 'equalto', interface.mac_address) | map(attribute='device') | first }}" interface_original_name: "{{ ansible_facts.interfaces | select('in', ansible_facts) | map('extract', ansible_facts) | selectattr('pciid', 'defined') | selectattr('macaddress', 'equalto', interface.mac_address) | map(attribute='device') | first }}"
+2 -2
View File
@@ -6,8 +6,8 @@ This role configures the networking on the target machine.
Roles: Roles:
- net-persist - net_persist
- net-config - net_config
## Inventory Variables ## Inventory Variables
+25 -2
View File
@@ -6,7 +6,7 @@
- name: Setup persistent network interface(s) - name: Setup persistent network interface(s)
ansible.builtin.include_role: ansible.builtin.include_role:
name: net-persist name: net_persist
public: true public: true
vars: vars:
interface: "{{ item }}" interface: "{{ item }}"
@@ -14,12 +14,35 @@
- name: Configure network interface(s) - name: Configure network interface(s)
ansible.builtin.include_role: ansible.builtin.include_role:
name: net-config name: net_config
public: true public: true
vars: vars:
interface: "{{ item }}" interface: "{{ item }}"
loop: "{{ hostvars[inventory_hostname].network_interfaces | default([]) }}" loop: "{{ hostvars[inventory_hostname].network_interfaces | default([]) }}"
- name: Remove stale podman-gw systemd-networkd configuration
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/network/10-podman-gw.netdev
- /etc/systemd/network/20-podman-gw.network
register: stale_podman_gw
- name: Mark networkd reload required after podman-gw cleanup
ansible.builtin.set_fact:
network_reload_required: true
when: stale_podman_gw is changed
- name: Tear down podman-gw bridge interface if present
ansible.builtin.command: ip link delete podman-gw
register: podman_gw_link_del
changed_when: podman_gw_link_del.rc == 0
failed_when:
- podman_gw_link_del.rc != 0
- "'Cannot find device' not in podman_gw_link_del.stderr"
- "'does not exist' not in podman_gw_link_del.stderr"
- name: Reload networkd and resolved - name: Reload networkd and resolved
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{ item }}" name: "{{ item }}"
@@ -16,3 +16,7 @@ nfs_port: 2049
nfs_server_firewall_allowed_sources: nfs_server_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
# OS-dependent package name
nfs_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}
@@ -1,7 +1,7 @@
--- ---
- name: Install nfs-server - name: Install nfs-server
ansible.builtin.package: ansible.builtin.package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}" name: "{{ nfs_package_name }}"
state: present state: present
- name: Configure nfs configuration - name: Configure nfs configuration
@@ -28,6 +28,11 @@
state: started state: started
enabled: true enabled: true
- name: Mask nfs-server service to prevent conflicts with nfsv4-server
ansible.builtin.systemd:
name: nfs-server
masked: true
- name: Setup firewall rules for nfs on port - name: Setup firewall rules for nfs on port
community.general.ufw: community.general.ufw:
rule: allow rule: allow
+16
View File
@@ -11,6 +11,22 @@ Installs and configures Nginx as a reverse proxy for web applications with modul
- SSL/TLS configuration - SSL/TLS configuration
- **Native ACME/Let's Encrypt support** (Nginx 1.25.0+) - **Native ACME/Let's Encrypt support** (Nginx 1.25.0+)
- **Transparent proxy forwarding** (HTTP/HTTPS to other hosts) - **Transparent proxy forwarding** (HTTP/HTTPS to other hosts)
- **Catch-all `default_server`** that rejects unknown SNI/Host with `444`
## Catch-all default_server
A `00-default.conf` vhost is deployed and marked `default_server` on both
ports 80 and 443. It uses a self-signed cert (`/etc/nginx/ssl/default.crt`)
and returns `444` (close connection) for any request whose SNI/Host does
not match an explicit vhost. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`)
are still answered on port 80 so Certbot keeps working for new hostnames.
Without this, clients hitting the server IP directly (or doing HTTP/2
connection coalescing across vhosts sharing the same IP) would receive the
certificate of the first vhost loaded alphabetically, leaking that
hostname and breaking TLS verification on other vhosts.
Disable with `nginx_default_server_enabled: false`.
## Service Integration Pattern ## Service Integration Pattern
+6
View File
@@ -17,6 +17,12 @@ nginx_client_max_body_size: 100M
# SSL configuration (volontarily omit TLSv1.2 here) # SSL configuration (volontarily omit TLSv1.2 here)
nginx_ssl_protocols: TLSv1.3 nginx_ssl_protocols: TLSv1.3
# Catch-all default_server (rejects unknown SNI / Host with 444).
# Prevents leaking the first-loaded vhost's cert to unrelated requests.
nginx_default_server_enabled: true
nginx_default_ssl_cert: /etc/nginx/ssl/default.crt
nginx_default_ssl_key: /etc/nginx/ssl/default.key
# Logging configuration # Logging configuration
# Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal) # Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal)
nginx_log_backend: journald nginx_log_backend: journald
+87
View File
@@ -0,0 +1,87 @@
---
# Provision a Let's Encrypt certificate for a hostname using the webroot method.
#
# Required variables:
# - certbot_hostname: the domain to provision (e.g. "apk.jokester.fr")
# - acme_email: Let's Encrypt account email (typically from host_vars)
#
# Usage from a service role:
# - name: Provision TLS certificate
# ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
# vars:
# certbot_hostname: "{{ myservice_nginx_hostname }}"
# when: myservice_nginx_enabled
- name: Validate certbot requirements
ansible.builtin.assert:
that:
- certbot_hostname is defined
- certbot_hostname | length > 0
- acme_email is defined
- acme_email | length > 0
fail_msg: |
certbot_hostname and acme_email are required for certificate provisioning.
Set acme_email in host_vars and pass certbot_hostname when including this task file.
success_msg: "Certbot requirements validated for {{ certbot_hostname }}"
- name: Check if certificate already exists for {{ certbot_hostname }}
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
register: certbot_cert_file
- name: Ensure letsencrypt directories are traversable by nginx
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
when: certbot_cert_file.stat.exists
- name: Provision certificate for {{ certbot_hostname }}
when: not certbot_cert_file.stat.exists
block:
- name: Deploy temporary HTTP-only vhost for ACME challenge
ansible.builtin.template:
src: "{{ role_path }}/../nginx/templates/vhost-http-acme.conf.j2"
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
owner: root
group: root
mode: "0644"
- name: Reload nginx to activate temporary ACME vhost
ansible.builtin.systemd:
name: nginx
state: reloaded
- name: Request certificate from Let's Encrypt for {{ certbot_hostname }}
ansible.builtin.command:
cmd: >-
certbot certonly
--webroot
-w /var/www/certbot
-d {{ certbot_hostname }}
--email {{ acme_email }}
--agree-tos
--non-interactive
creates: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
- name: Fix letsencrypt directory permissions for nginx
ansible.builtin.file:
path: "{{ item }}"
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
always:
- name: Remove temporary ACME vhost
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
state: absent
- name: Reload nginx after certificate provisioning
ansible.builtin.systemd:
name: nginx
state: reloaded
+49
View File
@@ -56,6 +56,12 @@
state: started state: started
when: acme_email is defined when: acme_email is defined
- name: Remove default nginx vhost (Arch ships one that conflicts)
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/default.conf"
state: absent
notify: Reload nginx
- name: Ensure nginx conf.d directory exists - name: Ensure nginx conf.d directory exists
ansible.builtin.file: ansible.builtin.file:
path: "{{ nginx_conf_dir }}" path: "{{ nginx_conf_dir }}"
@@ -72,6 +78,49 @@
group: root group: root
mode: "0755" mode: "0755"
- name: Configure catch-all default_server
when: nginx_default_server_enabled
block:
- name: Ensure nginx ssl directory exists
ansible.builtin.file:
path: "{{ nginx_default_ssl_cert | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Generate self-signed cert for default_server
ansible.builtin.command:
cmd: >-
openssl req -x509 -nodes -newkey rsa:2048
-keyout {{ nginx_default_ssl_key }}
-out {{ nginx_default_ssl_cert }}
-days 3650 -subj "/CN=default"
creates: "{{ nginx_default_ssl_cert }}"
- name: Restrict permissions on default_server key
ansible.builtin.file:
path: "{{ nginx_default_ssl_key }}"
owner: root
group: root
mode: "0600"
- name: Deploy default_server vhost
ansible.builtin.template:
src: default-server.conf.j2
dest: "{{ nginx_conf_dir }}/00-default.conf"
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Remove default_server vhost when disabled
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/00-default.conf"
state: absent
when: not nginx_default_server_enabled
notify: Reload nginx
- name: Ensure Certbot webroot directory exists - name: Ensure Certbot webroot directory exists
ansible.builtin.file: ansible.builtin.file:
path: /var/www/certbot path: /var/www/certbot
@@ -0,0 +1,35 @@
# Catch-all default_server vhosts
# Managed by Ansible - DO NOT EDIT MANUALLY
#
# Purpose: reject any request whose Host/SNI does not match an explicit
# server_name. Without this, the first vhost loaded alphabetically would
# leak its certificate to unrelated SNI requests (e.g. clients doing
# HTTP/2 connection coalescing or hitting the IP directly).
#
# `return 444` closes the connection without sending an HTTP response.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Keep ACME HTTP-01 challenges working for any hostname
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 444;
}
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate {{ nginx_default_ssl_cert }};
ssl_certificate_key {{ nginx_default_ssl_key }};
return 444;
}
-41
View File
@@ -1,41 +0,0 @@
# HTTPS vhost with Let's Encrypt (Certbot) for {{ server_name }}
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
server_name {{ server_name }};
# Certbot webroot for ACME challenges
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
server_name {{ server_name }};
# Let's Encrypt certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/{{ server_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ server_name }}/privkey.pem;
# SSL configuration
ssl_protocols {{ nginx_ssl_protocols }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }};
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }};
{% else %}
access_log /var/log/nginx/{{ server_name }}_access.log main;
error_log /var/log/nginx/{{ server_name }}_error.log;
{% endif %}
# Service-specific configuration included below
{{ vhost_config | default('') }}
}
@@ -0,0 +1,16 @@
# Temporary HTTP-only vhost for ACME certificate provisioning
# Managed by Ansible - automatically removed after certificate issuance
server {
listen 80;
listen [::]:80;
server_name {{ certbot_hostname }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 503;
}
}
+1 -4
View File
@@ -12,14 +12,11 @@ ntfy_admin_user: admin
# ntfy_admin_password: "" # Intentionally undefined - role will fail if not set # ntfy_admin_password: "" # Intentionally undefined - role will fail if not set
# Network configuration # Network configuration
ntfy_port: 8080 ntfy_port: 8090
# Container image # Container image
ntfy_image: binwiederhier/ntfy ntfy_image: binwiederhier/ntfy
# Timezone
ntfy_timezone: UTC
# Server configuration # Server configuration
ntfy_base_url: http://localhost:{{ ntfy_port }} ntfy_base_url: http://localhost:{{ ntfy_port }}
ntfy_behind_proxy: false ntfy_behind_proxy: false
+9 -4
View File
@@ -4,13 +4,18 @@
daemon_reload: true daemon_reload: true
- name: Reload systemd user - name: Reload systemd user
ansible.builtin.command: "systemctl --user daemon-reload" ansible.builtin.systemd:
become: true daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Restart ntfy - name: Restart ntfy
ansible.builtin.command: "systemctl --user restart ntfy.service" ansible.builtin.systemd:
become: true name: ntfy.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Reload nginx - name: Reload nginx
+34 -3
View File
@@ -37,6 +37,13 @@
mode: "0644" mode: "0644"
notify: Restart ntfy notify: Restart ntfy
- name: Pull ntfy container image
ansible.builtin.command: "podman pull {{ ntfy_image }}:{{ ntfy_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for ntfy - name: Deploy Kubernetes YAML for ntfy
ansible.builtin.template: ansible.builtin.template:
src: ntfy.yaml.j2 src: ntfy.yaml.j2
@@ -53,7 +60,7 @@
- name: Set user home directory fact - name: Set user home directory fact
ansible.builtin.set_fact: ansible.builtin.set_fact:
user_home_dir: "{{ getent_passwd[ansible_user][4] }}" user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for ntfy - name: Create systemd user directory for ntfy
ansible.builtin.file: ansible.builtin.file:
@@ -72,12 +79,25 @@
mode: "0644" mode: "0644"
notify: Reload systemd user notify: Reload systemd user
- name: Check if lingering is enabled for {{ ansible_user }}
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ ansible_user }}"
register: linger_file
- name: Enable lingering for user {{ ansible_user }} - name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}" ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root' changed_when: true
when:
- ansible_user != 'root'
- not linger_file.stat.exists
- name: Enable and start ntfy service (user scope) - name: Enable and start ntfy service (user scope)
ansible.builtin.command: "systemctl --user enable --now ntfy.service" ansible.builtin.systemd:
name: ntfy.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Wait for ntfy to be ready - name: Wait for ntfy to be ready
@@ -92,23 +112,34 @@
register: ntfy_user_list register: ntfy_user_list
changed_when: false changed_when: false
failed_when: false failed_when: false
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Create admin user in ntfy - name: Create admin user in ntfy
ansible.builtin.shell: | ansible.builtin.shell: |
set -o pipefail
printf '%s\n%s\n' '{{ ntfy_admin_password }}' '{{ ntfy_admin_password }}' | podman exec -i ntfy-server ntfy user add --role=admin {{ ntfy_admin_user }} printf '%s\n%s\n' '{{ ntfy_admin_password }}' '{{ ntfy_admin_password }}' | podman exec -i ntfy-server ntfy user add --role=admin {{ ntfy_admin_user }}
when: ntfy_admin_user not in ntfy_user_list.stdout when: ntfy_admin_user not in ntfy_user_list.stdout
register: ntfy_user_create register: ntfy_user_create
changed_when: ntfy_user_create.rc == 0 changed_when: ntfy_user_create.rc == 0
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Set admin user password - name: Set admin user password
ansible.builtin.shell: | ansible.builtin.shell: |
set -o pipefail
printf '%s\n%s\n' '{{ ntfy_admin_password }}' '{{ ntfy_admin_password }}' | podman exec -i ntfy-server ntfy user change-pass {{ ntfy_admin_user }} printf '%s\n%s\n' '{{ ntfy_admin_password }}' '{{ ntfy_admin_password }}' | podman exec -i ntfy-server ntfy user change-pass {{ ntfy_admin_user }}
when: ntfy_admin_user in ntfy_user_list.stdout when: ntfy_admin_user in ntfy_user_list.stdout
changed_when: false changed_when: false
become: false
become_user: "{{ ansible_user }}" become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for ntfy
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ ntfy_nginx_hostname }}"
when: ntfy_nginx_enabled
- name: Deploy nginx vhost configuration for ntfy - name: Deploy nginx vhost configuration for ntfy
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
+2
View File
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ ntfy_nginx_hostname }}; server_name {{ ntfy_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
server_name {{ ntfy_nginx_hostname }}; server_name {{ ntfy_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # Let's Encrypt certificates (managed by Certbot)
+5 -4
View File
@@ -2,13 +2,14 @@
Description=Ntfy Notification Service Description=Ntfy Notification Service
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy
ExecStart=/usr/bin/podman play kube --replace ntfy.yaml ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} ntfy.yaml
ExecStop=/usr/bin/podman play kube --down ntfy.yaml ExecStop=/usr/bin/podman kube down ntfy.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+7 -7
View File
@@ -14,9 +14,6 @@ spec:
ports: ports:
- containerPort: 80 - containerPort: 80
hostPort: {{ ntfy_port }} hostPort: {{ ntfy_port }}
env:
- name: TZ
value: "{{ ntfy_timezone }}"
volumeMounts: volumeMounts:
- name: localtime - name: localtime
mountPath: /etc/localtime mountPath: /etc/localtime
@@ -29,14 +26,17 @@ spec:
- name: ntfy-data - name: ntfy-data
mountPath: /var/lib/ntfy mountPath: /var/lib/ntfy
livenessProbe: livenessProbe:
httpGet: exec:
path: /v1/health command:
port: 80 - wget
- -q
- -O-
- http://localhost:80/v1/health
initialDelaySeconds: 40 initialDelaySeconds: 40
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
failureThreshold: 3 failureThreshold: 3
restartPolicy: Always restartPolicy: Never
volumes: volumes:
- name: localtime - name: localtime

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