Initial commit

This commit is contained in:
drew 2026-04-30 02:14:51 -04:00
commit c482d75d5f
52 changed files with 1482 additions and 0 deletions

0
.codex Normal file
View File

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# Ansible variable files (sensitive / environment-specific)
group_vars/
host_vars/
commands/
# Inventory files (often environment-specific and not meant for sharing)
inventory/*
inventory/group_vars/*
inventories/*
# Keep sanitized examples
!inventory/
!inventory/hosts.example.yml
!inventory/group_vars/
!inventory/group_vars/*.example.yml
# Codex/local assistant config
.codex/
# Ansible Vault / secrets
*.vault
*.secret
*.secrets
secrets.yml
vault.yml
**/vault.yml
**/secrets.yml
# Local environment files
.env
.env.*
*.local
# Optional: Ansible retry files
*.retry
# Python/cache
__pycache__/
.pytest_cache/
# OS/editor noise
.DS_Store
Thumbs.db
.vscode/
.idea/
# Logs
*.log

24
bootstrap.yml Normal file
View File

@ -0,0 +1,24 @@
- name: Bootstrap SSH access
hosts: all
become: true
tasks:
- name: Ensure .ssh directory exists
file:
path: "/home/{{ bootstrap_user }}/.ssh"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0700"
- name: Install authorized key
authorized_key:
user: "{{ bootstrap_user }}"
state: present
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
- name: Allow passwordless sudo
copy:
dest: /etc/sudoers.d/{{ bootstrap_user }}
content: "{{ bootstrap_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"

View File

@ -0,0 +1,91 @@
---
# Global settings shared by all inventory groups.
timezone: America/New_York
container_user: ansible
container_group: ansible
container_uid: 1000
container_runtime_dir: "/run/user/{{ container_uid }}"
bootstrap_user: "{{ container_user }}"
deployment_directory: "/home/{{ container_user }}/sbots1"
# Stack directories
stack_root: "/home/{{ container_user }}/lab"
adguard_dir: "{{ stack_root }}/adguard"
prowlarr_dir: "{{ stack_root }}/prowlarr"
qbittorrent_dir: "{{ stack_root }}/qbittorrent"
radarr_dir: "{{ stack_root }}/radarr"
sonarr_dir: "{{ stack_root }}/sonarr"
bazarr_dir: "{{ stack_root }}/bazarr"
caddy_dir: "{{ stack_root }}/caddy"
nas01_dir: /mnt/nas01
downloads_root: "{{ nas01_dir }}"
# Container paths
container_config_dir: "/home/{{ container_user }}/.config/containers/systemd"
adguard_base_directories:
- "{{ stack_root }}"
- "{{ adguard_dir }}/work/data"
- "{{ adguard_dir }}/conf"
servarr_base_directories:
- "{{ stack_root }}"
- "{{ prowlarr_dir }}/config"
- "{{ qbittorrent_dir }}/config"
- "{{ radarr_dir }}/config"
- "{{ sonarr_dir }}/config"
- "{{ bazarr_dir }}/config"
caddy_base_directories:
- "{{ stack_root }}"
- "{{ caddy_dir }}/data"
- "{{ caddy_dir }}/config/caddy"
- "{{ container_config_dir }}"
storage_tree:
- data
- data/media/movies
- data/media/tv
- data/media/music
- data/torrents/movies
- data/torrents/tv
- data/torrents/music
- data/torrents/incomplete
- data/torrents/complete
arr_suite:
- qbittorrent
- prowlarr
- radarr
- sonarr
- bazarr
- flaresolverr
base_firewall_rules:
- service: ssh
adguard_firewall_rules:
- port: 53/tcp
- port: 53/udp
- port: 80/tcp
- port: 443/tcp
- port: 3000/tcp
nfs_exports:
- path: "{{ nas01_dir }}"
clients:
- "192.168.1.11(rw,sync,no_subtree_check,fsid=0)"
- "192.168.1.12(rw,sync,no_subtree_check,fsid=0)"
- "192.168.1.14(rw,sync,no_subtree_check,fsid=0)"
adguard_admin_user: ansible
adguard_admin_password_hash: "replace-with-adguard-bcrypt-hash"
nfs_server_name: 192.168.1.10
nfs_export_location: /
nfs_export_path: "{{ nas01_dir }}"
nfs_export_type: nfs4
nfs_export_options: rw,sync,no_subtree_check,fsid=0

View File

@ -0,0 +1,25 @@
---
# Gluetun SELinux access
selinux_allow_gluetun: true
# Servarr stack
servarr_stack:
- src: qbittorrent.container.j2
dest: qbittorrent.container
- src: prowlarr.container.j2
dest: prowlarr.container
- src: radarr.container.j2
dest: radarr.container
- src: sonarr.container.j2
dest: sonarr.container
- src: bazarr.container.j2
dest: bazarr.container
- src: flaresolverr.container.j2
dest: flaresolverr.container
# Gluetun setup
vpn_provider: mullvad
vpn_type: wireguard
vpn_countries: "Netherlands,USA,Canada"
vpn_private_key: "replace-with-wireguard-private-key"
vpn_addresses: "10.0.0.2/32"

View File

@ -0,0 +1,21 @@
---
# Caddy
adguard_domain: "adguard.example.{{ caddy_node }}"
adguard_upstream: adguard:80
qbittorrent_domain: "qbittorrent.example.{{ caddy_node }}"
qbittorrent_upstream: host.containers.internal:8080
prowlarr_domain: "prowlarr.example.{{ caddy_node }}"
prowlarr_upstream: host.containers.internal:9696
radarr_domain: "radarr.example.{{ caddy_node }}"
radarr_upstream: host.containers.internal:7878
sonarr_domain: "sonarr.example.{{ caddy_node }}"
sonarr_upstream: host.containers.internal:8989
bazarr_domain: "bazarr.example.{{ caddy_node }}"
bazarr_upstream: host.containers.internal:6767
caddy_email: "admin@example.{{ caddy_node }}"

View File

@ -0,0 +1,2 @@
---
# Media host variables go here when the media role needs host-specific settings.

View File

@ -0,0 +1,11 @@
---
# MergerFS
drive_a_path: /mnt/disk1
drive_b_path: /mnt/disk2
mergerfs_path: /mnt/nas01
mergerfs_opts: "defaults,allow_other,use_ino,category.create=mfs"
storage_backends:
- "{{ drive_a_path }}"
- "{{ drive_b_path }}"

View File

@ -0,0 +1,2 @@
---
caddy_node: com

View File

@ -0,0 +1,2 @@
---
caddy_node: test

View File

@ -0,0 +1,2 @@
---
# Workstation host variables go here when workstation-specific settings are needed.

View File

@ -0,0 +1,73 @@
all:
children:
test:
hosts:
nas-test:
ansible_host: 192.168.122.10
ansible_user: ansible
env: test
media-test:
ansible_host: 192.168.122.11
ansible_user: ansible
env: test
controller-test:
ansible_host: 192.168.122.12
ansible_user: ansible
env: test
bots-test:
ansible_host: 192.168.122.13
ansible_user: ansible
env: test
workstation-test:
ansible_host: 192.168.122.14
ansible_user: ansible
env: test
prod:
hosts:
nas01:
ansible_host: 192.168.1.10
ansible_user: ansible
env: prod
media1:
ansible_host: 192.168.1.11
ansible_user: ansible
env: prod
bots1:
ansible_host: 192.168.1.12
ansible_user: ansible
env: prod
controller1:
ansible_host: 192.168.1.13
ansible_user: ansible
env: prod
workstation1:
ansible_host: 192.168.1.14
ansible_user: ansible
env: prod
nas:
hosts:
nas-test:
nas01:
media:
hosts:
media-test:
media1:
controller:
hosts:
controller-test:
controller1:
bots:
hosts:
bots-test:
bots1:
workstation:
hosts:
workstation-test:
workstation1:

46
playbook.yml Normal file
View File

@ -0,0 +1,46 @@
- name: Storage
hosts: nas
become: true
roles:
- base_os
- firewall_base
- container_runtime
- storage_client
- nfs_server
- name: Jellyfin
hosts: media
become: true
roles:
- base_os
- firewall_base
- container_runtime
- name: Bots
hosts: bots
become: true
roles:
- base_os
- firewall_base
- container_runtime
- nfs_client
- servarr
- name: DNS
hosts: controller
become: true
roles:
- base_os
- firewall_base
- container_runtime
- adguard
- caddy
- name: Workstation Setup
hosts: workstation
become: true
roles:
- base_os
- firewall_base
- container_runtime
- selinux_containers

View File

@ -0,0 +1,4 @@
---
# adguard/meta/main.yml
dependencies:
- role: selinux_containers

View File

@ -0,0 +1,11 @@
---
#adguard/tasks/firewall.yml
- name: Open required firewall rules
become: true
ansible.posix.firewalld:
port: "{{ item.port | default(omit) }}"
service: "{{ item.service | default(omit) }}"
permanent: true
state: enabled
immediate: true
loop: "{{ adguard_firewall_rules }}"

View File

@ -0,0 +1,58 @@
---
#adguard/tasks/main.yml
- import_tasks: firewall.yml
- name: Create stack and config directories
file:
path: "{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
recurse: yes
loop: "{{ adguard_base_directories }}"
- name: Directory SELinux requirement
ansible.builtin.set_fact:
selinux_container_paths: "{{ adguard_base_directories }}"
- import_role:
name: selinux_containers
tasks_from: labels
- name: Ensure container app config directories are owned by container UID
become: true
file:
path: "{{ adguard_dir }}/conf"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
recurse: true
- name: Deploy AdGuard configuration template
template:
src: AdGuardHome.yaml.j2
dest: "{{ stack_root }}/adguard/conf/AdGuardHome.yaml"
mode: '0600'
- name: Deploy AdGuard Quadlet
template:
src: adguard.container.j2
dest: "{{ container_config_dir }}/adguard.container"
- name: Force systemd reload (blocking)
become: true
become_user: "{{ container_user }}"
command: systemctl --user daemon-reload
- name: Wait for quadlet generation
pause:
seconds: 1
- name: Start and enable AdGuard service
become: true
become_user: "{{ container_user }}"
systemd:
name: adguard.service
scope: user
state: started

View File

@ -0,0 +1,194 @@
http:
pprof:
port: 6060
enabled: false
doh:
routes:
- GET /dns-query
- POST /dns-query
- GET /dns-query/{ClientID}
- POST /dns-query/{ClientID}
insecure_enabled: false
address: 0.0.0.0:80
session_ttl: 720h
users:
- name: "{{ adguard_admin_user }}"
password: "{{ adguard_admin_password_hash }}"
auth_attempts: 5
block_auth_min: 15
http_proxy: ""
language: ""
theme: auto
dns:
bind_hosts:
- 0.0.0.0
port: 53
anonymize_client_ip: false
ratelimit: 20
ratelimit_subnet_len_ipv4: 24
ratelimit_subnet_len_ipv6: 56
ratelimit_whitelist: []
refuse_any: true
upstream_dns:
- https://dns10.quad9.net/dns-query
upstream_dns_file: ""
bootstrap_dns:
- 9.9.9.10
- 149.112.112.10
- 2620:fe::10
- 2620:fe::fe:10
fallback_dns: []
upstream_mode: load_balance
fastest_timeout: 1s
allowed_clients: []
disallowed_clients: []
blocked_hosts:
- version.bind
- id.server
- hostname.bind
trusted_proxies:
- 127.0.0.0/8
- ::1/128
cache_enabled: true
cache_size: 4194304
cache_ttl_min: 0
cache_ttl_max: 0
cache_optimistic: false
cache_optimistic_answer_ttl: 30s
cache_optimistic_max_age: 12h
bogus_nxdomain: []
aaaa_disabled: false
enable_dnssec: false
edns_client_subnet:
custom_ip: ""
enabled: false
use_custom: false
max_goroutines: 300
handle_ddr: true
ipset: []
ipset_file: ""
bootstrap_prefer_ipv6: false
upstream_timeout: 10s
private_networks: []
use_private_ptr_resolvers: true
local_ptr_upstreams: []
use_dns64: false
dns64_prefixes: []
serve_http3: false
use_http3_upstreams: false
serve_plain_dns: true
hostsfile_enabled: true
pending_requests:
enabled: true
tls:
enabled: false
server_name: ""
force_https: false
port_https: 443
port_dns_over_tls: 853
port_dns_over_quic: 853
port_dnscrypt: 0
dnscrypt_config_file: ""
certificate_chain: ""
private_key: ""
certificate_path: ""
private_key_path: ""
strict_sni_check: false
querylog:
dir_path: ""
ignored: []
interval: 2160h
size_memory: 1000
enabled: true
ignored_enabled: false
file_enabled: true
statistics:
dir_path: ""
ignored: []
interval: 24h
enabled: true
ignored_enabled: false
filters:
- enabled: true
url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
name: AdGuard DNS filter
id: 1
- enabled: false
url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_2.txt
name: AdAway Default Blocklist
id: 2
whitelist_filters: []
user_rules: []
dhcp:
enabled: false
interface_name: ""
local_domain_name: lan
dhcpv4:
gateway_ip: ""
subnet_mask: ""
range_start: ""
range_end: ""
lease_duration: 86400
icmp_timeout_msec: 1000
options: []
dhcpv6:
range_start: ""
lease_duration: 86400
ra_slaac_only: false
ra_allow_slaac: false
filtering:
blocking_ipv4: ""
blocking_ipv6: ""
blocked_services:
schedule:
time_zone: UTC
ids: []
protection_disabled_until: null
safe_search:
enabled: false
bing: true
duckduckgo: true
ecosia: true
google: true
pixabay: true
yandex: true
youtube: true
blocking_mode: default
parental_block_host: family-block.dns.adguard.com
safebrowsing_block_host: standard-block.dns.adguard.com
rewrites: []
safe_fs_patterns:
- /opt/adguardhome/work/userfilters/*
safebrowsing_cache_size: 1048576
safesearch_cache_size: 1048576
parental_cache_size: 1048576
cache_time: 30
filters_update_interval: 24
blocked_response_ttl: 10
filtering_enabled: true
rewrites_enabled: true
parental_enabled: false
safebrowsing_enabled: false
protection_enabled: true
clients:
runtime_sources:
whois: true
arp: true
rdns: true
dhcp: true
hosts: true
persistent: []
log:
enabled: true
file: ""
max_backups: 0
max_size: 100
max_age: 3
compress: false
local_time: false
verbose: false
os:
group: ""
user: ""
rlimit_nofile: 0
schema_version: 34

View File

@ -0,0 +1,22 @@
[Unit]
Description=AdGuard Home DNS
Wants=network-online.target
After=network-online.target homelab-network.service
Requires=homelab-network.service
[Container]
Image=docker.io/adguard/adguardhome:latest
ContainerName=adguard
Network=homelab:alias=adguard
Volume={{ adguard_dir }}/work:/opt/adguardhome/work
Volume={{ adguard_dir }}/conf:/opt/adguardhome/conf
PublishPort=53:53/tcp
PublishPort=53:53/udp
[Service]
Restart=always
[Install]
WantedBy=default.target

View File

@ -0,0 +1,8 @@
---
#base_os/defaults/main.yml
base_os_install_packages:
- slirp4netns
- fuse-overlayfs
- policycoreutils-python-utils
- nano
- tree

View File

@ -0,0 +1,10 @@
---
#base_os/tasks/main.yml
- import_tasks: time_sync.yml
- name: Install required base packages
become: true
dnf:
name: "{{ item }}"
state: present
loop: "{{ base_os_install_packages }}"

View File

@ -0,0 +1,29 @@
---
#base_os/tasks/time_sync.yml
- name: Chrony time sync (dev only)
when: env == "test"
block:
- name: Ensure chronyd is running
become: true
ansible.builtin.service:
name: chronyd
state: started
enabled: true
- name: Wait for chrony to have reachable sources
become: true
command: chronyc activity
register: chrony_activity
retries: 20
delay: 2
until: "'sources online' in chrony_activity.stdout and '0 sources online' not in chrony_activity.stdout"
- name: Force time step correction
become: true
command: chronyc -a makestep
- name: Verify system time is reasonable
command: date
register: date_check
failed_when: "'2026-04-13' in date_check.stdout"

View File

@ -0,0 +1,4 @@
---
# caddy/meta/main.yml
dependencies:
- role: selinux_containers

View File

@ -0,0 +1,49 @@
---
#caddy/tasks/main.yml
- name: Create stack and config directories
file:
path: "{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
recurse: yes
loop: "{{ caddy_base_directories }}"
- name: Base SELinux requirement
ansible.builtin.set_fact:
selinux_container_paths: "{{ caddy_base_directories }}"
- import_role:
name: selinux_containers
tasks_from: labels
- name: Ensure Caddyfile is deployed
template:
src: Caddyfile.j2
dest: "{{ caddy_dir }}/Caddyfile"
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0644"
- name: Deploy quadlet
template:
src: caddy.container.j2
dest: "{{ container_config_dir }}/caddy.container"
- name: Force systemd reload (blocking)
become: true
become_user: "{{ container_user }}"
command: systemctl --user daemon-reload
- name: Wait for quadlet generation
pause:
seconds: 1
- name: Start service
become: true
become_user: "{{ container_user }}"
systemd:
name: caddy.service
scope: user
state: started

View File

@ -0,0 +1,35 @@
# Adguard
{{ adguard_domain }} {
tls internal
reverse_proxy {{ adguard_upstream }}
}
# QBittorrent
{{ qbittorrent_domain }} {
tls internal
reverse_proxy {{ qbittorrent_upstream }}
}
# Prowlarr
{{ prowlarr_domain }} {
tls internal
reverse_proxy {{ prowlarr_upstream }}
}
# Radarr
{{ radarr_domain }} {
tls internal
reverse_proxy {{ radarr_upstream }}
}
# Sonarr
{{ sonarr_domain }} {
tls internal
reverse_proxy {{ sonarr_upstream }}
}
# Bazarr
{{ bazarr_domain }} {
tls internal
reverse_proxy {{ bazarr_upstream }}
}

View File

@ -0,0 +1,24 @@
[Unit]
Description=Caddy Reverse Proxy
Wants=network-online.target
After=network-online.target
Requires=homelab-network.service
After=homelab-network.service
[Container]
Image=docker.io/caddy:latest
ContainerName=caddy
Network=homelab
Volume={{ caddy_dir }}/Caddyfile:/etc/caddy/Caddyfile
Volume={{ caddy_dir }}/data:/data
Volume={{ caddy_dir }}/config:/config
PublishPort=80:80/tcp
PublishPort=443:443/tcp
[Service]
Restart=always
[Install]
WantedBy=default.target

View File

@ -0,0 +1,5 @@
---
#container_runtime/defaults/main.yml
base_runtime_install_packages:
- podman
- podman-docker

View File

@ -0,0 +1,64 @@
#container_runtime/tasks/main.yml
- name: Install required base packages
become: true
dnf:
name: "{{ item }}"
state: present
loop: "{{ base_runtime_install_packages }}"
- name: Enable lingering for rootless containers
become: true
command: "loginctl enable-linger {{ container_user }}"
args:
creates: "/var/lib/systemd/linger/{{ container_user }}"
- name: Allow rootless to bind to low ports
become: true
sysctl:
name: net.ipv4.ip_unprivileged_port_start
value: '53'
state: present
- name: Create stack directories
file:
path: "{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
recurse: yes
loop:
- "{{ stack_root }}"
- "{{ container_config_dir }}"
- name: Configure SELinux container policies
ansible.builtin.import_tasks: ../selinux_containers/tasks/main.yml
- name: Deploy Podman Network Quadlet
become: true
template:
src: homelab.network.j2
dest: "{{ container_config_dir }}/homelab.network"
mode: "0644"
owner: "{{ container_user }}"
group: "{{ container_group }}"
#- name: Force systemd reload (blocking)
# become: true
# become_user: "{{ container_user }}"
# command: systemctl --user daemon-reload
- name: Force systemd reload (blocking)
become: true
become_user: "{{ container_user }}"
environment:
XDG_RUNTIME_DIR: "{{ container_runtime_dir }}"
command: systemctl --user daemon-reload
- name: Start homelab network
become: true
become_user: "{{ container_user }}"
systemd:
name: homelab-network.service
scope: user
state: started

View File

@ -0,0 +1,2 @@
[Network]
NetworkName=homelab

View File

@ -0,0 +1,25 @@
---
#firewall_base/tasks/main.yml
- name: Install required base packages
become: true
dnf:
name:
- firewalld
state: present
- name: Enable firewalld
become: true
systemd:
name: firewalld
enabled: true
state: started
- name: Open required firewall rules
become: true
ansible.posix.firewalld:
port: "{{ item.port | default(omit) }}"
service: "{{ item.service | default(omit) }}"
permanent: true
state: enabled
immediate: true
loop: "{{ base_firewall_rules }}"

View File

@ -0,0 +1,5 @@
nfs_server: "{{ nfs_server_name }}"
nfs_export: "{{ nfs_export_location }}"
nfs_mount_point: "{{ nfs_export_path }}"
nfs_fstype: "{{ nfs_export_type }}"
nfs_options: "defaults,_netdev,nofail,x-systemd.automount,x-systemd.requires=network-online.target,x-systemd.after=network-online.target,x-systemd.idle-timeout=60"

View File

@ -0,0 +1,73 @@
---
#nfs_client/tasks/main.yml
- name: Create dummy NAS root for test environment
become: true
file:
path: "{{ nfs_mount_point }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
when: env == "test"
- name: Create dummy NAS storage tree for test environment
become: true
file:
path: "{{ nfs_mount_point }}/{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0775"
loop: "{{ storage_tree }}"
when: env == "test"
- name: Set SELinux context for dummy NAS storage in test environment
become: true
community.general.sefcontext:
target: "{{ nfs_mount_point }}(/.*)?"
setype: container_file_t
state: present
when: env == "test"
- name: Apply SELinux context for dummy NAS storage in test environment
become: true
command: restorecon -Rv "{{ nfs_mount_point }}"
changed_when: false
when: env == "test"
- name: Install required NFS client packages
become: true
dnf:
name: nfs-utils
state: present
when: env != "test"
- name: Check whether NFS mount point is already mounted
become: true
command: findmnt --mountpoint "{{ nfs_mount_point }}"
register: nfs_mount_check
changed_when: false
failed_when: false
when: env != "test"
- name: Create NFS mount point
become: true
file:
path: "{{ nfs_mount_point }}"
state: directory
owner: root
group: root
mode: "0755"
when:
- env != "test"
- nfs_mount_check.rc != 0
- name: Configure NFS mount
become: true
ansible.posix.mount:
path: "{{ nfs_mount_point }}"
src: "{{ nfs_server }}:{{ nfs_export }}"
fstype: "{{ nfs_fstype }}"
opts: "{{ nfs_options }}"
state: mounted
when: env != "test"

View File

@ -0,0 +1,4 @@
#nfw_server/defaults/main.yml
nfs_packages:
- nfs-utils
- nfs-server

View File

@ -0,0 +1,29 @@
---
#nfs_server/tasks/main.yml
- name: Install NFS utilities
become: true
dnf:
name: nfs-utils
state: present
loop: "{{ nfs_packages }}"
- name: Build NFS exports entries
become: true
lineinfile:
path: /etc/exports
line: "{{ item.path }} {{ item.clients | join(' ') }}"
create: true
state: present
loop: "{{ nfs_exports }}"
- name: Export NFS shares
become: true
command: exportfs -ra
changed_when: false
- name: Enable and start NFS server
become: true
systemd:
name: nfs-server
enabled: true
state: started

View File

@ -0,0 +1,12 @@
---
# selinux_containers/tasks/labels.yml
- name: Ensure SELinux context for container config paths
become: true
community.general.sefcontext:
target: "{{ item }}(/.*)?"
setype: container_file_t
state: present
loop: "{{ selinux_container_paths | default([]) }}"
when:
- selinux_container_paths is defined
- selinux_container_paths | length > 0

View File

@ -0,0 +1,19 @@
---
#selinux_containers/tasks/main.yml
# -----------------------------
# Set SELinux
# -----------------------------
- name: Set SELinux context for stack
become: true
community.general.sefcontext:
target: "{{ stack_root }}(/.*)?"
setype: container_file_t
state: present
# -----------------------------
# APPLY SELinux
# -----------------------------
- name: Apply SELinux context (stack)
become: true
command: restorecon -Rv "{{ stack_root }}"
changed_when: false

View File

@ -0,0 +1,17 @@
---
# selinux_containers/tasks/storage.yml
- name: Set SELinux context for storage mounts
become: true
community.general.sefcontext:
target: "{{ item }}(/.*)?"
setype: container_file_t
state: present
loop: "{{ storage_backends }}"
when: "'nas' in group_names"
- name: Apply SELinux context (storage)
become: true
command: restorecon -R -F -v "{{ item }}"
loop: "{{ storage_backends }}"
changed_when: false
when: "'nas' in group_names"

View File

@ -0,0 +1,12 @@
---
# selinux_containers/tasks/vpn.yml
# -----------------------------
# SELinux BOOLEANS
# -----------------------------
- name: Allow containers to use devices (needed for Gluetun / /dev/net/tun)
become: true
ansible.posix.seboolean:
name: container_use_devices
state: true
persistent: true
when: selinux_allow_gluetun | default(false)

View File

@ -0,0 +1,9 @@
---
#servarr/defaults/main.yml
servarr_firewall_rules:
- port: 8080/tcp
- port: 6767/tcp
- port: 7878/tcp
- port: 8191/tcp
- port: 8989/tcp
- port: 9696/tcp

View File

@ -0,0 +1,5 @@
---
# servarr/meta/main.yml
dependencies:
- role: selinux_containers
- role: vpn

View File

@ -0,0 +1,11 @@
---
#servarr/tasks/firewall.yml
- name: Open required firewall rules
become: true
ansible.posix.firewalld:
port: "{{ item.port | default(omit) }}"
service: "{{ item.service | default(omit) }}"
permanent: true
state: enabled
immediate: true
loop: "{{ servarr_firewall_rules }}"

View File

@ -0,0 +1,66 @@
#servarr/tasks/main.yml
- import_tasks: firewall.yml
- name: Create stack and config directories
file:
path: "{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
recurse: yes
loop: "{{ servarr_base_directories }}"
- name: Directory SELinux requirement
ansible.builtin.set_fact:
selinux_container_paths: "{{ servarr_base_directories }}"
- import_role:
name: selinux_containers
tasks_from: labels
- name: Ensure systemd directory exists for rootless user
file:
path: "{{ container_config_dir }}"
state: directory
mode: '0755'
owner: "{{ container_user }}"
group: "{{ container_group }}"
- name: Deploy Quadlet files
template:
src: "{{ item.src }}"
dest: "{{ container_config_dir }}/{{ item.dest }}"
loop: "{{ servarr_stack }}"
register: quadlets_deployed
- name: Ensure app config directories are writable
become: true
file:
path: "{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
recurse: true
loop:
- "{{ radarr_dir }}/config"
- "{{ sonarr_dir }}/config"
- "{{ qbittorrent_dir }}/config"
- "{{ prowlarr_dir }}/config"
- "{{ bazarr_dir }}/config"
#- name: Force systemd reload (blocking)
# become: true
# become_user: "{{ container_user }}"
# command: systemctl --user daemon-reload
- name: Force systemd reload (blocking)
become: true
become_user: "{{ container_user }}"
environment:
XDG_RUNTIME_DIR: "{{ container_runtime_dir }}"
command: systemctl --user daemon-reload
- name: Validate VPN and start arr stack
ansible.builtin.import_role:
name: vpn_guard

View File

@ -0,0 +1,18 @@
[Unit]
Description=Bazarr Subtitle Manager
Requires=gluetun.service
After=gluetun.service
[Container]
ContainerName=bazarr
Image=lscr.io/linuxserver/bazarr:latest
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/New_York
Network=container:gluetun
Volume={{ bazarr_dir }}/config:/config
Volume={{ downloads_root }}:/data
[Install]
WantedBy=default.target

View File

@ -0,0 +1,15 @@
[Unit]
Description=Flaresolverr Cloudflare Solver
Requires=gluetun.service
After=gluetun.service
[Container]
ContainerName=flaresolverr
Image=ghcr.io/flaresolverr/flaresolverr:latest
Environment=TZ=America/New_York
Environment=LOG_LEVEL=info
Network=container:gluetun
[Install]
WantedBy=default.target

View File

@ -0,0 +1,17 @@
[Unit]
Description=Prowlarr Indexer Manager
Requires=gluetun.service
After=gluetun.service
[Container]
ContainerName=prowlarr
Image=lscr.io/linuxserver/prowlarr:latest
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/New_York
Network=container:gluetun
Volume={{ prowlarr_dir }}/config:/config
[Install]
WantedBy=default.target

View File

@ -0,0 +1,23 @@
[Unit]
Description=qBittorrent Downloader
Requires=gluetun.service
After=gluetun.service
StartLimitIntervalSec=0
[Container]
Image=lscr.io/linuxserver/qbittorrent:latest
ContainerName=qbittorrent
Environment=PUID=0
Environment=PGID=0
Environment=TZ={{ timezone }}
Environment=WEBUI_PORT=8080
Environment=WEBUI_ADDRESS=0.0.0.0
Network=container:gluetun
Volume={{ qbittorrent_dir }}/config:/config
Volume={{ downloads_root }}:/data
[Install]
WantedBy=default.target

View File

@ -0,0 +1,20 @@
[Unit]
Description=Radarr Movie Manager
Requires=gluetun.service
After=gluetun.service
[Container]
ContainerName=radarr
Image=lscr.io/linuxserver/radarr:latest
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/New_York
Network=container:gluetun
Volume={{ radarr_dir }}/config:/config
Volume={{ downloads_root }}:/data
[Install]
WantedBy=default.target

View File

@ -0,0 +1,20 @@
[Unit]
Description=Sonarr TV Manager
Requires=gluetun.service
After=gluetun.service
[Container]
ContainerName=sonarr
Image=lscr.io/linuxserver/sonarr:latest
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/New_York
Network=container:gluetun
Volume={{ sonarr_dir }}/config:/config
Volume={{ downloads_root }}:/data
[Install]
WantedBy=default.target

View File

@ -0,0 +1,7 @@
---
#storage_client/defaults/main.yml
base_storage_install_packages:
- epel-release
- fuse
- fuse3
- policycoreutils-python-utils

View File

@ -0,0 +1,43 @@
---
#storage_client/tasks/main.yml
- name: Install required packages
become: true
dnf:
name: "{{ item }}"
state: present
loop: "{{ base_storage_install_packages }}"
- name: Install mergerfs repo package
become: true
dnf:
name: https://github.com/trapexit/mergerfs/releases/download/2.41.1/mergerfs-2.41.1-1.el10.x86_64.rpm
disable_gpg_check: true
state: present
- name: Ensure source data directories exist
become: true
file:
path: "{{ item }}"
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
loop: "{{ storage_backends }}"
- name: Create mergerfs mountpoint
become: true
file:
path: /mnt/nas01
state: directory
owner: "{{ container_user }}"
group: "{{ container_group }}"
mode: "0755"
- name: Mount mergerfs pool
become: true
ansible.posix.mount:
path: "{{ mergerfs_path }}"
src: "{{ drive_a_path }}/data:{{ drive_b_path }}/data"
fstype: fuse.mergerfs
opts: defaults,allow_other,use_ino,category.create=mfs
state: mounted

82
roles/vpn/tasks/main.yml Normal file
View File

@ -0,0 +1,82 @@
---
#vpn/tasks/main.yml
- name: Install kernel extra modules for Gluetun firewall
become: true
dnf:
name: kernel-modules-extra
state: present
register: kernel_modules_extra_install
- name: Check whether xt_conntrack is available for running kernel
become: true
command: modinfo xt_conntrack
register: xt_conntrack_modinfo
changed_when: false
failed_when: false
- name: Reboot if kernel modules were installed but running kernel cannot find xt_conntrack
become: true
reboot:
msg: "Rebooting to load kernel matching installed kernel-modules-extra for Gluetun firewall"
reboot_timeout: 600
when:
- xt_conntrack_modinfo.rc != 0
- name: Load xt_conntrack for Gluetun firewall
become: true
community.general.modprobe:
name: xt_conntrack
state: present
- name: Persist xt_conntrack module
become: true
copy:
dest: /etc/modules-load.d/gluetun.conf
mode: "0644"
content: |
xt_conntrack
- name: Configure SELinux for Gluetun device access
ansible.builtin.import_role:
name: selinux_containers
tasks_from: vpn
- name: Deploy Quadlet files
template:
src: "gluetun.container.j2"
dest: "{{ container_config_dir }}/gluetun.container"
- name: Force systemd reload (blocking)
become: true
become_user: "{{ container_user }}"
environment:
XDG_RUNTIME_DIR: "{{ container_runtime_dir }}"
command: systemctl --user daemon-reload
- name: Wait for quadlet generation
pause:
seconds: 1
- name: Start vpn
become: true
become_user: "{{ container_user }}"
environment:
XDG_RUNTIME_DIR: "{{ container_runtime_dir }}"
systemd:
name: gluetun.service
scope: user
state: started
- name: Wait for Gluetun container to exist
become: true
become_user: "{{ container_user }}"
command: podman container exists gluetun
register: gluetun_exists
retries: 20
delay: 2
until: gluetun_exists.rc == 0
changed_when: false
- name: Wait for Gluetun to stabilize
pause:
seconds: 5

View File

@ -0,0 +1,44 @@
[Unit]
Description=Gluetun VPN Client
Requires=homelab-network.service
After=network-online.target homelab-network.service
Wants=network-online.target
[Container]
Image=docker.io/qmcgaw/gluetun:latest
ContainerName=gluetun
UserNS=keep-id
AddCapability=NET_ADMIN
AddCapability=NET_RAW
AddDevice=/dev/net/tun:/dev/net/tun
# Port Mappings:
# 6767 Bazarr
# 7878 Radarr
# 8080 qBittorrent
# 8191 Flaresolverr
# 8989 Sonarr
# 9696 Prowlarr
PublishPort=6767:6767
PublishPort=7878:7878
PublishPort=8080:8080
PublishPort=8191:8191
PublishPort=8989:8989
PublishPort=9696:9696
Environment=VPN_SERVICE_PROVIDER=mullvad
Environment=VPN_TYPE=wireguard
Environment=WIREGUARD_PRIVATE_KEY={{ vpn_private_key }}
Environment=WIREGUARD_ADDRESSES={{ vpn_addresses }}
Environment=FIREWALL_ENABLED=on
#Environment=FIREWALL_ENABLED_DISABLING_IT_SHOOTS_YOU_IN_YOUR_FOOT=off
Environment=DNS_ENABLED=false
Environment=FIREWALL_INPUT_PORTS=6767,7878,8080,8191,8989,9696
Network=homelab:alias=gluetun
[Install]
WantedBy=default.target

View File

@ -0,0 +1,40 @@
---
#vpn_guard/tasks/main.yml
- name: Get host public IP
command: curl -s https://ipinfo.io/ip
register: host_ip
changed_when: false
- name: Get VPN public IP (via Gluetun)
become: true
become_user: "{{ container_user }}"
command: podman exec gluetun wget -qO- https://ipinfo.io/ip
register: vpn_ip
changed_when: false
- name: Fail if VPN is not active (kill switch check)
fail:
msg: "VPN is NOT active (host={{ host_ip.stdout }} vpn={{ vpn_ip.stdout }}). Aborting arr stack start."
when: host_ip.stdout == vpn_ip.stdout
#- name: Start arr stack only after VPN validation
# become: true
# become_user: "{{ container_user }}"
# systemd:
# name: "{{ item }}.service"
# enabled: yes
# state: started
# scope: user
# loop: "{{ arr_suite }}"
- name: Start arr stack only after VPN validation
become: true
become_user: "{{ container_user }}"
environment:
XDG_RUNTIME_DIR: "{{ container_runtime_dir }}"
systemd:
name: "{{ item }}.service"
enabled: yes
state: started
scope: user
loop: "{{ arr_suite }}"