Compare commits

...

21 Commits

Author SHA1 Message Date
Adyrem c707a7cceb Add Anki route and fix Jellyfin cache disk exhaustion
Ansible Lint / lint (push) Successful in 6s
- Add adyrem.duckdns.org/Anki → claude-code VM:4002 via Traefik
- Add anki.homelab DNS entry to Pi-hole
- Mount Jellyfin transcode cache to /media/transcodes (HDD) instead of
  anonymous Docker volume on root disk; prevents root disk from filling up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:02:21 +02:00
Adyrem bc838dfcb6 Add jellyfin media stack with full service automation
Ansible Lint / lint (push) Successful in 7s
- New jellyfin Ansible role: Docker stack (Jellyfin, qBittorrent, Radarr,
  Sonarr, Prowlarr, FlareSolverr, Jellyseerr) with NVIDIA GPU passthrough
- configure_services.yml automates download clients, root folders, Prowlarr
  indexers/apps, qBittorrent credential pre-seeding, and Jellyseerr setup
- Fix TPB Cardigann season search: apibay.org returns 0 for season-level
  queries (e.g. the.boys.s05); patch strips season/ep from tv-search params
  and locks the definition file read-only to survive Prowlarr refreshes
- Fix Pi-hole update_hosts.py regex: use re.DOTALL + count=1 to handle
  multi-line arrays without overwriting the DHCP section
- Add phone WireGuard peer, devbox Traefik/DNS entries, TLS cert plumbing,
  firewall rule for Traefik IP to Proxmox web UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:31:30 +02:00
Adyrem f4c1651d17 Fix NVIDIA installer task; remove jellyfin-tools reference file
Ansible Lint / lint (push) Successful in 4s
- Switch NVIDIA installer copy to get_url so the LXC downloads it
  directly from NVIDIA's servers rather than expecting the file on the
  Ansible controller
- Remove jellyfin-tools (original compose reference from upstream);
  the Ansible template is now the authoritative compose file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:41:44 +02:00
Adyrem 3bb01ddaaf Add Jellyfin media stack (LXC 106, NVIDIA GTX 1060)
- New Ansible role: jellyfin — Docker + nvidia-container-toolkit setup,
  full *arr stack compose file (Jellyfin, qBittorrent, Radarr, Sonarr,
  Prowlarr, FlareSolverr, Jellyseerr), systemd service for auto-start
- New playbook: jellyfin.yml targeting media_servers group
- Inventory: add media LXC (10.10.1.50) to media_servers group
- Traefik: add internal .homelab routes for all 6 media services
- Pi-hole: add DNS records for all 6 media services → Traefik
- network-interfaces: DNAT port 6881 TCP+UDP → 10.10.1.50 for BitTorrent
- homelab-requirements.md: document full Jellyfin stack requirements
  including NVIDIA GTX 1060 passthrough approach and driver notes
- jellyfin-tools: original reference compose file from upstream

Infrastructure created on Proxmox:
- LXC 106 (media, Debian 12, privileged, nesting=1, 10.10.1.50/vmbr1)
- ZFS dataset hdd/media mounted at /media in LXC
- NVIDIA device passthrough entries in /etc/pve/lxc/106.conf

Note: NVIDIA driver 580.159.03 (.run) installed on Proxmox host — not
managed by apt (Debian only has 550.x which doesn't support kernel 7.x
or GTX 1060 Pascal as of this driver version).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:28:37 +02:00
Adyrem 30b2d42fcc Enforce firewall=0 on vmbr2 VMs to prevent NAT breakage
Ansible Lint / lint (push) Successful in 5s
When a VM has firewall=1, Proxmox inserts a fwbr bridge and iptables
POSTROUTING fires at the bridge step (OUT=fwbrNNNi0) rather than at
vmbr0. The MASQUERADE rule (-o vmbr0) never matches, so the VM loses
internet access.

Adds an idempotent task to the proxmox_host role that strips
firewall=1 from VM 105's net0 config whenever the playbook runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:39:08 +02:00
Adyrem 80a3250cf6 Add /mtg route to Traefik and Pi-hole DNS
Routes adyrem.duckdns.org/mtg → http://10.10.2.10:3000 (claude-code-vm)
with StripPrefix so the app sees requests at /.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:24:33 +02:00
Adyrem 1d878a8d45 Scope port 80/443 DNAT to Proxmox public IP only
Without -d 192.168.1.10, the DNAT rule intercepted all TCP port 80/443
traffic arriving on vmbr0 — including LAN or routed traffic destined for
internal VMs like Pi-hole (10.10.1.2), redirecting it to Traefik instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:46:48 +02:00
Adyrem b9cb93b691 Remove scheduled_tasks.lock from tracking and gitignore it
Ansible Lint / lint (push) Successful in 4s
Runtime lock file created by the Claude Code harness — should never be committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 04:55:11 +02:00
Adyrem f2321e1f45 Persist static IPs via Ansible to survive VM reboots
common role: add nmcli task that ensures the static IP, gateway, and DNS
are configured in NetworkManager — idempotent, only applies when
network_gateway is defined. Fixes Gitea going offline after reboot
because the NM connection had reverted to DHCP.

group_vars/infra: add network vars (10.10.1.0/24, gw 10.10.1.1)
group_vars/claude_code_vms: add network vars (10.10.2.0/24, gw 10.10.2.1)
  also fix ProxyJump to use adyrem@:2222 (root login was disabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 04:54:01 +02:00
Adyrem 1cf6c81ac5 Add sanoid Ansible role for ZFS snapshots and replication
Replaces manually deployed config files with a fully idempotent role:
installs sanoid, templates sanoid.conf and syncoid service/timer from
variables, enables both systemd timers. Removes static proxmox/sanoid.*
files. README rebuild section updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 04:22:05 +02:00
Adyrem 18e05c2f88 Add README with rebuild procedure, VPN setup, and operations guide
Covers: infrastructure overview, SSH aliases, playbook usage, full
bare-metal rebuild sequence, WireGuard client setup (Linux/Windows/macOS),
ZFS snapshot restore, dev VM provisioning, monitoring onboarding,
and secrets management.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 04:16:17 +02:00
Adyrem 7ba749c620 Add Proxmox host role, WireGuard VPN, and public Gitea via Traefik HTTPS
Ansible Lint / lint (push) Successful in 4s
Proxmox host role:
- Admin user setup, SSH hardening (port 2222, no root login)
- claude-code SFTP chroot user
- Unattended upgrades, DuckDNS dynamic DNS updater (vault-encrypted token)
- WireGuard VPN server (10.10.10.0/24) with Fedora client registered
- Proxmox firewall management via pvesh API (idempotent Python script)

Public Gitea exposure:
- Traefik: add HTTPS entrypoint, Let's Encrypt ACME (HTTP-01), StripPrefix
  middleware for /git subpath, HTTP→HTTPS redirect
- Gitea ROOT_URL updated to https://adyrem.duckdns.org/git/
- Pi-hole split DNS: adyrem.duckdns.org → 10.10.1.3 for internal hairpin bypass

SSH config fixes:
- StrictHostKeyChecking no → accept-new across all hosts
- Proxmox jump host: port 2222, user adyrem (root login disabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 04:06:56 +02:00
Adyrem e888560175 Add pre-commit hook to detect secrets
Blocks commits containing:
- Private key file headers (RSA/EC/DSA/OPENSSH)
- Plaintext passwords/tokens ≥16 chars
- Gitea/GitHub-style hex tokens
- Files with private key path suffixes (_ed25519, _rsa, etc.)

Allowlisted: Ansible vault variable references ({{ vault_* }}),
encrypted vault blocks ($ANSIBLE_VAULT), and comment lines.

Run scripts/install-hooks.sh after cloning to activate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:03:43 +02:00
Adyrem 7ba68de68d CI: trigger re-run to verify vault secret fix
Ansible Lint / lint (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:57:39 +02:00
Adyrem d1d38df0e4 Track LXC container configs and fix network-interfaces
- proxmox/lxc/103.conf: Pi-hole LXC (firewall=0 to fix NAT via fwbr)
- proxmox/lxc/104.conf: Traefik LXC
- proxmox/network-interfaces: Proxmox network config with DNAT/MASQUERADE
  rules; fixes tpost-up typo so DNS DNAT persists across reboots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:52:43 +02:00
Adyrem 62e5027d24 Add Proxmox firewall configs and Grafana dashboards
- proxmox/firewall/host.fw: host firewall rules (SSH/Proxmox UI/SPICE
  from LAN, DNS from internal subnets, ICMP)
- proxmox/firewall/cluster.fw: cluster firewall with network aliases
- files/grafana-dashboards/node-exporter-full.json: Node Exporter Full
  (grafana.com/dashboards/1860)
- files/grafana-dashboards/loki-logs.json: Logs/App dashboard for Loki
  (grafana.com/dashboards/13639)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:35:36 +02:00
Adyrem 3d596b984b Add Claude Code VM (105) with cloud-init provisioning
Arch Linux cloud image, 4 cores/8GB, dev-net (10.10.2.10), autostart.
Cloud-init handles user/SSH key/network on first boot. Ansible role
installs Claude Code CLI, tmux, SSH keys, SSH config for all managed
VMs via ProxyJump, homelab repo clone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:41:52 +02:00
Adyrem 36c63d1243 Add archinstall config for Claude Code VM (105, 10.10.2.10, dev-net)
Ansible Lint / lint (push) Failing after 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:04:30 +02:00
Adyrem afd240de86 Add Pi-hole and Traefik LXC containers
- LXC 103 (pihole): Debian 12, 10.10.1.2, Pi-hole v6 with local DNS for
  gitea/grafana/traefik/pihole.homelab, Proxmox dnsmasq updated to use
  Pi-hole upstream
- LXC 104 (traefik): Debian 12, 10.10.1.3, Traefik v3 routing
  gitea.homelab → 10.10.1.125:3000, grafana.homelab → 10.10.1.137:3000
- DNAT rules on vmbr0 forwarding :80/:443 to Traefik, persisted in
  /etc/network/interfaces
- Ansible roles: pihole, traefik; playbooks: pihole.yml, traefik.yml
- site.yml excludes Debian containers from Arch-specific common role

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:52:20 +02:00
Adyrem c626177daa Enable autostart for Gitea and monitoring VMs
Set onboot=1 on VM 100 (gitea) and VM 101 (monitoring) so they start
automatically on Proxmox host boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:18:49 +02:00
Adyrem d50451fde4 Add Windows VM config and update remote access docs
VM 102: Windows 11 Home N, 8 cores/16GB, OVMF+TPM2, QXL/SPICE display,
vmbr3 (desktop-net). SPICE used instead of RDP — Home N has no RDP server.
Clean-install snapshot taken and synced to HDD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:14:50 +02:00
76 changed files with 18807 additions and 78 deletions
+2
View File
@@ -1,8 +1,10 @@
keys/
wireguard/
certs/
*.vault
*.secret
ansible/vault_pass
user_credentials.json
archinstall-configs/creds.json
vms/
.claude/scheduled_tasks.lock
+267
View File
@@ -0,0 +1,267 @@
# Homelab
Proxmox VE homelab running on an Intel i7-8700 / 32 GB machine. All infrastructure is managed via Ansible. Secrets are encrypted with `ansible-vault`.
---
## Infrastructure Overview
| Component | Type | IP | Purpose |
|---|---|---|---|
| Proxmox host | bare metal | 192.168.1.10 | Hypervisor, WireGuard VPN server |
| gitea | VM 100 | 10.10.1.125 | Self-hosted Git + CI runner |
| monitoring | VM 101 | 10.10.1.137 | Prometheus, Grafana, Loki, Alertmanager |
| windows | VM 102 | — | Visual Studio (SPICE via Proxmox UI) |
| pihole | LXC 103 | 10.10.1.2 | DNS + ad blocking |
| traefik | LXC 104 | 10.10.1.3 | Reverse proxy + Let's Encrypt |
| claude-code | VM 105 | 10.10.2.10 | Persistent Claude Code session VM |
**Networks:**
- `vmbr1` 10.10.1.0/24 — infra (Gitea, monitoring, Pi-hole, Traefik)
- `vmbr2` 10.10.2.0/24 — dev (Claude Code VM)
- `vmbr3` 10.10.3.0/24 — desktop (Windows VM, isolated)
**Public endpoints:**
- `https://adyrem.duckdns.org/git` — Gitea
- Grafana: `http://10.10.1.137:3000` (VPN/LAN only)
- Proxmox UI: `https://10.10.1.10:8006` (VPN/LAN only; use `https://10.10.10.1:8006` over WireGuard)
---
## Prerequisites
- Ansible installed locally (`pip install ansible ansible-lint`)
- Vault password stored at `~/.config/homelab/vault_pass`
- WireGuard connected (for remote access) or on the LAN
```bash
cd ansible
export ANSIBLE_VAULT_PASSWORD_FILE=~/.config/homelab/vault_pass
```
---
## Day-to-day Operations
### SSH aliases (defined in `~/.ssh/config`)
```bash
ssh proxmox # Proxmox host (port 2222, user adyrem)
ssh gitea-vm # Gitea VM (via ProxyJump)
ssh monitoring-vm # Monitoring VM (via ProxyJump)
ssh pihole-ct # Pi-hole LXC (via ProxyJump)
ssh traefik-ct # Traefik LXC (via ProxyJump)
ssh claude-code-vm # Claude Code VM (via ProxyJump)
```
### Running playbooks
```bash
cd ansible
ansible-playbook playbooks/host.yml # Proxmox host config
ansible-playbook playbooks/gitea.yml # Gitea VM
ansible-playbook playbooks/monitoring.yml # Monitoring stack
ansible-playbook playbooks/pihole.yml # Pi-hole
ansible-playbook playbooks/traefik.yml # Traefik
ansible-playbook playbooks/claude-code-vm.yml
ansible-playbook playbooks/site.yml # All Arch VMs (common + node_exporter + promtail)
```
### Adding a WireGuard client
See [`ansible/playbooks/wireguard-README.md`](ansible/playbooks/wireguard-README.md) for step-by-step instructions.
---
## VPN Client Setup
WireGuard VPN is hosted on the Proxmox host (`adyrem.duckdns.org:51820`).
### Linux
```bash
# Install WireGuard
sudo apt install wireguard # Debian/Ubuntu
sudo pacman -S wireguard-tools # Arch
# Create config at /etc/wireguard/wg0.conf (see wireguard-README.md for template)
sudo systemctl enable --now wg-quick@wg0
# Verify
ping 10.10.10.1 # Proxmox host
```
### Windows
1. Download and install the [WireGuard app](https://www.wireguard.com/install/)
2. Open WireGuard → **Add Tunnel****Add empty tunnel**
3. Copy the generated public key — register it as a peer (see wireguard-README.md)
4. Paste the full client config, click **Activate**
### macOS / Android / iOS
Import the config file into the WireGuard app. The config template is in [`ansible/playbooks/wireguard-README.md`](ansible/playbooks/wireguard-README.md).
**Split tunnel:** only homelab traffic routes through the VPN (10.10.10.0/24, 10.10.1.0/24, 10.10.2.0/24). Internet traffic goes direct.
---
## Full Rebuild from Bare Metal
### 1. Install Proxmox
Boot the Proxmox VE ISO. During install:
- Disk: SSD (ZFS, single disk)
- Hostname: `homelab`
- IP: 192.168.1.10/24, gateway 192.168.1.1
- Timezone: Europe/Zurich
### 2. Restore ZFS data from HDD backup
SSH into the freshly installed host as `root`:
```bash
# Import HDD pool
zpool import hdd
# Restore VM disks
zfs receive -F rpool/data < <(syncoid hdd/backups/rpool-data rpool/data)
# Restore Proxmox OS datasets (optional — only if SSD was lost)
zfs receive -F rpool/ROOT/pve-1 < <(syncoid hdd/backups/pve-root rpool/ROOT/pve-1)
```
Or use syncoid directly for incremental restore:
```bash
syncoid --recursive hdd/backups/rpool-data rpool/data
```
### 3. Configure Proxmox host via Ansible
On first run, the playbook connects as `root` (before hardening):
```bash
cd ansible
ansible-playbook playbooks/host.yml
```
This creates `adyrem`, hardens SSH (port 2222, no root login), sets up the `claude-code` SFTP user, WireGuard, and DuckDNS. Root SSH access ends after this run.
Subsequent runs use `adyrem` (already set in `group_vars/proxmox_hosts/vars.yml`).
### 4. Configure network and firewall
Edit `/etc/network/interfaces` on Proxmox to restore the DNAT rules (port 80/443 → Traefik, port 53 → Pi-hole). Template is tracked at `proxmox/network-interfaces`.
Apply host firewall rules:
```bash
ansible-playbook playbooks/host.yml
```
### 5. Start infrastructure VMs/CTs and run playbooks
```bash
# Start VMs (or they start automatically if autostart was set)
# Run infra playbooks
cd ansible
ansible-playbook playbooks/pihole.yml
ansible-playbook playbooks/traefik.yml
ansible-playbook playbooks/gitea.yml
ansible-playbook playbooks/monitoring.yml
ansible-playbook playbooks/claude-code-vm.yml
```
### 6. Set up sanoid/syncoid for ZFS snapshots
Handled automatically by `playbooks/host.yml` (the `sanoid` role). No manual steps needed.
### 7. Restore Grafana password
Grafana bootstraps with password `admin`. Log in at `http://10.10.1.137:3000` and change it when prompted.
### 8. Reconfigure Internet-Box
- Port forward UDP 51820 → 192.168.1.10 (WireGuard)
- Port forward TCP 80 → 192.168.1.10 (Let's Encrypt HTTP-01)
- Port forward TCP 443 → 192.168.1.10 (HTTPS)
---
## Restore a VM from ZFS Snapshot
List available snapshots:
```bash
zfs list -t snapshot -r rpool/data
```
Roll back a VM disk to a snapshot:
```bash
# Stop the VM first (e.g., VM 100)
qm stop 100
# Roll back to snapshot
zfs rollback rpool/data/vm-100-disk-0@autosnap_2026-05-15_00:00:00_daily
# Start the VM
qm start 100
```
Restore a VM from the HDD backup pool (e.g., after SSD failure):
```bash
zfs send hdd/backups/rpool-data/vm-100-disk-0@<snapshot> | zfs receive rpool/data/vm-100-disk-0
```
---
## Provisioning a New Dev VM
Dev VMs run on `vmbr2` (10.10.2.0/24) so Claude Code has no path to infra.
1. Create the VM in the Proxmox UI (or clone an existing one). Assign a static IP on `vmbr2`.
2. Add it to the inventory:
```ini
# ansible/inventory/hosts.yml
dev_vms:
hosts:
my-new-vm:
ansible_host: 10.10.2.X
```
3. Run the base playbook:
```bash
ansible-playbook playbooks/site.yml --limit my-new-vm
```
4. Add an SSH alias in `~/.ssh/config` and in `ansible/roles/claude_code/templates/ssh_config.j2`, then re-run `ansible-playbook playbooks/claude-code-vm.yml`.
---
## Adding a New VM to Monitoring
Node Exporter and Promtail are installed by `site.yml` on all Arch VMs automatically.
For the VM to appear in Prometheus, add it to the scrape config in `ansible/roles/monitoring/templates/prometheus.yml.j2` under the appropriate job, then re-run:
```bash
ansible-playbook playbooks/monitoring.yml
```
Grafana dashboards are provisioned automatically from the datasource. For custom dashboards, export the JSON from Grafana UI and save to `files/grafana-dashboards/`.
---
## Secrets Management
Secrets are encrypted with `ansible-vault`. The vault password lives at `~/.config/homelab/vault_pass` (never committed).
```bash
# Encrypt a new secret
ansible-vault encrypt_string 'mysecret' --name 'vault_my_var'
# Edit an existing vault file
ansible-vault edit ansible/inventory/group_vars/all/vault.yml
# Run a playbook with vault
ansible-playbook playbooks/gitea.yml # picks up vault_pass from env var set above
```
+37 -32
View File
@@ -1,33 +1,38 @@
$ANSIBLE_VAULT;1.1;AES256
34333966316434323536326366663637633834393865623238636338306361393837393530363665
3566366534376133643163376261343461653336643136330a356162653062663538636466346438
31626464363033623331353037333131356434386631616566323337616166306431383330333261
6464656132613962610a306163353135653131623064386337643563646266623839383138326133
31353466653633323030326432633364376236313239623439663265393365356262393138666263
62323962306439643166613930383933323132316132363236363639333336663237643966386337
63633764663263303933336332343266333161333835363235303965373134386434373532646137
38663965396332356561363932633736613836393861626435666162396666633033393963353264
35633438316238623933623966306330363432386632653936323436656337306430383439356462
64396137316464363835373137366434323263343330313163653638306434616635656537656264
66396366666562643734313235613364623734613662663161636332353035346666373732613165
64356630323239363665383933363139613738323866326134613661663731396566323065383438
39613335383434613432666266343935646537613266386335393131333635343633653736373634
64653164396439363631643565663839623966343165666236303434666431636431356136613335
36353166336664333031663435323161363137386237343034383863356430323433343766646134
36366665313130373363303136346239633538393230313832663430393233663062343834383534
64373363376139663165363663333962353538326461353838383865653963343036366336343563
65613336386438373262666263633761386332303737613038333735336639613230336636333265
30393735643432623935363832313738376630373036623630663735343935386165616133333563
63366637313961653264346539383233346465383263353566326134396333663632636630343133
64636261346437333166316430643437393032396237353231346136363634353838376238386335
39613865346266396639353838316331356430343331633330636534316335633563623464613661
34646132333135303361336135303263636362353937346635356464656438623539353836383132
65626566366539306536663462373236616430343135633439643836363365323761373463356131
65363337313334393265383362666639636132623032303764326335306336616136646165376264
30383561303866646138643734666462653765653362383163346432303435663738633936383866
39613831323436393061666532343765333331313961636164346338656561353731323064316630
32626238633863353530356436383665363239663965356564356239313334393732363265656665
30386266313538336339303731386634643839383261383361333931333739383563316134666331
66323064353733303838393635653037373538616138323763396536343064323633656562643337
39636138633066356134303739363061653065643464393331626531623030653766313764623630
64636434636233653065
31323435376435353462306465366666306330303932626563666533616264343239336236653865
6362303830313335363234663639323132313064383234650a613661353637613934666538653036
65343031666139326139626634653861633161633565333539663339633435306664396564656564
3361396638313836340a616539323161333465336162363063643832363231653037346234363533
66313661343562643663623132333138663536643062323064353632666365653435623233643634
61363034363562643733323164633130666133623034373639383663613661323065383230396265
65366331636235633534636237383332643436336665663366653164313266353236363362383864
32646131613136393863383031646163356463396662346231366365363436393961333462303434
64313738393132636537653438393030623363373037343930636137373436646630303137393035
33666238363039623266666538623634323165363262636565393134393530343137383965303662
30336536646431633235623636316132613437306338646435383037633534306336393538666237
39656330376339323464313134326165316163393965633964343136633035653934386633646133
32386235373031383037613835373966356334383539383162393136616566376335366239323961
38616334343238373361306561656635653334633561366139386465333462336161333637643234
63393231353166376362323434396265303630663530373538373665326662386264383762623835
37366234313930376366386336303963376232393066373133616332613439343430303664656137
37646630373332636136653634346363323766376635343838336531613934626362393961656266
65363333346664633261666132633166656665316537343464653436393662656565393136613839
30633932333062653836616637366365333937366134613965623139323163316332333636646439
64363238346430346437666363373463376630363539613365313231373237366666353737383264
39633261623231323962383564346265386633646664386461646332646637613363613134333163
65396362313139633436623839333032346436626633336165343665346133353330623833333530
30623362373261633630356563346231616462666336316165376230623166306362623734376538
36373039333538376634376534376431363462383764366462643436323964313361643732393964
37633635346433653636333432653365636666633963363230646636393035613335336231303138
32623735353465653230333264393137396265373866363666303264656135393464343633653063
33396539393566363735663537626634326432643836393161373837643561653432393237386337
32353962363964383363393833666165363334303462653736346538663762353133666165303034
39313833353465316531643238353238316231303638373964363861623766326565316438313839
38393735643635633431643634633436626335353462623766363663323132636263383235616237
65326334346462316630376532656338306438303639353537373766333566376238646265663237
65623366373766376636303033616566313131386564316135323436653563326464643662383534
38393061346339346461323438656239336236646166343738663232373565393763313365363439
61633639623661323132616138346433376134616334356534346332356635316333373931353265
35346432653231353864363935383966333336303962646563336133613336353239646361376532
31303830386335306237376366316663646136643462636162383134306431633065393338646139
3063
@@ -0,0 +1,7 @@
ansible_user: adyrem
ansible_ssh_common_args: "-o ProxyJump=adyrem@192.168.1.10:2222 -o StrictHostKeyChecking=accept-new"
network_interface: ens18
network_prefix: 24
network_gateway: 10.10.2.1
network_dns: 10.10.1.2
@@ -1 +1,6 @@
ansible_user: adyrem
network_interface: ens18
network_prefix: 24
network_gateway: 10.10.1.1
network_dns: 10.10.1.2
@@ -0,0 +1,2 @@
ansible_user: root
ansible_become: false
@@ -0,0 +1,20 @@
ansible_user: adyrem
ansible_become: true
ansible_become_method: sudo
ansible_port: 2222
ssh_port: 2222
claude_code_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINgCFi4cju1/dmyXkVtO1azFt9VvWNoYjuUjcw7IlJdC claude-code@homelab"
duckdns_domain: adyrem
duckdns_token: "{{ vault_duckdns_token }}"
wireguard_port: 51820
wireguard_peers:
- name: fedora
public_key: "ivtchk9pxEwYwusMzmn7Uq89LFV3uB1I8iYto0EJuy4="
allowed_ips: 10.10.10.2/32
- name: phone
public_key: "72fMMJTC/Ew38E61as+oEjFKsgKk4DAK8FqDuQOAcEU="
allowed_ips: 10.10.10.3/32
@@ -0,0 +1,8 @@
vault_duckdns_token: !vault |
$ANSIBLE_VAULT;1.1;AES256
34386432626362373134333765386630616466383661623463363465353135633763623364366166
3233343837653638613262663537383630376635323031350a653464653166396165643136643163
66376663666332303337306165313337303261373033363834373031303335383839383139663461
3036356435613435660a323432303964326362646462653139656664653439346331323261636436
62343135373431326231333736613836366238663064623163336636393663633862383439363333
6530326532373062636237613765353039373164333063613331
@@ -0,0 +1,2 @@
ansible_user: root
ansible_become: false
+27
View File
@@ -9,6 +9,10 @@ all:
ansible_host: 10.10.1.125
monitoring:
ansible_host: 10.10.1.137
pihole:
ansible_host: 10.10.1.2
traefik:
ansible_host: 10.10.1.3
monitoring_servers:
hosts:
@@ -17,3 +21,26 @@ all:
gitea_servers:
hosts:
gitea:
claude_code_vms:
hosts:
claude-code:
ansible_host: 10.10.2.10
media_servers:
hosts:
media:
ansible_host: 10.10.1.50
pihole_servers:
hosts:
pihole:
traefik_servers:
hosts:
traefik:
proxmox_hosts:
hosts:
proxmox:
ansible_host: 192.168.1.10
+8
View File
@@ -0,0 +1,8 @@
---
- name: Configure Claude Code VM
hosts: claude_code_vms
roles:
- common
- node_exporter
- promtail
- claude_code
+84
View File
@@ -0,0 +1,84 @@
# host.yml — Proxmox Host Playbook
Configures the Proxmox host (`192.168.1.10`) with:
- Admin user (`adyrem`) with SSH key auth and passwordless sudo
- `claude-code` SFTP-only user, chrooted to `/home/claude-code/workspace`
- SSH hardened: key-only auth, root login disabled, port changed to 2222
- Unattended security upgrades (Debian security channel only)
---
## Prerequisites
- Proxmox is freshly installed and accessible as `root` via SSH on port 22
- Ansible is installed on the machine running the playbook
- Vault password is at `~/.config/homelab/vault_pass`
- Repo is cloned and you're in the `ansible/` directory
---
## First run (bootstrap)
The first run connects as `root` because `adyrem` doesn't exist yet.
`group_vars/proxmox_hosts/vars.yml` is already set for this — no changes needed.
```bash
ansible-playbook playbooks/host.yml
```
What happens:
1. Creates `adyrem` with your SSH key and passwordless sudo
2. Creates `claude-code` user with SFTP-only access
3. Changes SSH port to 2222 and disables root login
4. Restarts sshd — **root SSH access ends here**
---
## After the first run
Update `inventory/group_vars/proxmox_hosts/vars.yml` to connect as `adyrem`:
```yaml
ansible_user: adyrem
ansible_become: true
ansible_become_method: sudo
ansible_port: 2222
```
Verify you can still connect before relying on this:
```bash
ssh -p 2222 adyrem@192.168.1.10
```
All subsequent runs use:
```bash
ansible-playbook playbooks/host.yml
```
---
## claude-code SFTP access
The `claude-code` user can only SFTP into `/home/claude-code/workspace`. No shell, no TCP forwarding, no escape from the chroot.
From the Claude Code VM:
```bash
sftp -P 2222 claude-code@192.168.1.10
# lands in /workspace (which is /home/claude-code/workspace on the host)
```
The private key for this user lives in the Claude Code VM at `~/.ssh/claude-code_ed25519`.
The corresponding public key is committed at `keys/claude-code_ed25519.pub`.
---
## Re-running safely
The playbook is idempotent. Re-running it after the group_vars update will:
- Ensure all config is still correct
- Apply any template changes (sshd drop-in, unattended-upgrades)
- Restart sshd only if the config changed
+14
View File
@@ -0,0 +1,14 @@
---
# Proxmox host configuration: admin user, claude-code SFTP user, SSH hardening,
# unattended security upgrades.
#
# BOOTSTRAP NOTE: First run connects as root (see group_vars/proxmox_hosts/vars.yml).
# After the first run, root SSH login is disabled — update the group_vars to:
# ansible_user: adyrem
# ansible_become: true
# ansible_port: 2222
- name: Configure Proxmox host
hosts: proxmox_hosts
roles:
- proxmox_host
- sanoid
+6
View File
@@ -0,0 +1,6 @@
---
- name: Deploy Jellyfin media stack
hosts: media_servers
become: true
roles:
- jellyfin
+5
View File
@@ -0,0 +1,5 @@
---
- name: Deploy Pi-hole
hosts: pihole_servers
roles:
- pihole
+12 -2
View File
@@ -1,6 +1,6 @@
---
- name: Common setup for all VMs
hosts: all
- name: Common setup for all Arch VMs
hosts: all:!pihole_servers:!traefik_servers
roles:
- common
- node_exporter
@@ -15,3 +15,13 @@
hosts: gitea_servers
roles:
- gitea
- name: Pi-hole
hosts: pihole_servers
roles:
- pihole
- name: Traefik
hosts: traefik_servers
roles:
- traefik
+5
View File
@@ -0,0 +1,5 @@
---
- name: Deploy Traefik
hosts: traefik_servers
roles:
- traefik
+112
View File
@@ -0,0 +1,112 @@
# Adding a new WireGuard client
## Current peer assignments
| Name | IP |
|--------|--------------|
| server | 10.10.10.1 |
| fedora | 10.10.10.2 |
| phone | 10.10.10.3 |
Pick the next free IP (10.10.10.3, 10.10.10.4, …) for each new machine.
---
## Step 1 — Generate a keypair on the new machine
**Linux / macOS:**
```bash
wg genkey | tee ~/wg-client.key | wg pubkey
```
**Windows** (WireGuard app installed):
Open the WireGuard app → Add Tunnel → Add empty tunnel. It generates a keypair and shows the public key at the top.
Copy the **public key** — you need it in Step 2.
Keep the **private key** local. Never commit it to git.
---
## Step 2 — Register the peer in Ansible
Edit `ansible/inventory/group_vars/proxmox_hosts/vars.yml` and add an entry to `wireguard_peers`:
```yaml
wireguard_peers:
- name: fedora
public_key: "ivtchk9pxEwYwusMzmn7Uq89LFV3uB1I8iYto0EJuy4="
allowed_ips: 10.10.10.2/32
- name: my-new-machine # ← add this
public_key: "<public key from Step 1>"
allowed_ips: 10.10.10.X/32 # ← next free IP
```
---
## Step 3 — Push the peer to the server
```bash
cd ansible && ansible-playbook playbooks/host.yml
```
The server will accept connections from the new client immediately after this.
---
## Step 4 — Create the client config
Use the template below. Store it in a location that is **not** committed to git.
```ini
[Interface]
Address = 10.10.10.X/32
PrivateKey = <private key from Step 1>
DNS = 10.10.1.2
[Peer]
PublicKey = UJaAvoT65+qC7NdD4lKFK+/J1OxKBYp1ZY3ynHjqcHE=
Endpoint = adyrem.duckdns.org:51820
AllowedIPs = 10.10.10.0/24, 10.10.1.0/24, 10.10.2.0/24
PersistentKeepalive = 25
```
`AllowedIPs` is a split tunnel — only homelab traffic goes through the VPN.
Replace `adyrem.duckdns.org` with `192.168.1.10` when connecting from inside the LAN.
---
## Step 5 — Connect
**Linux (one-off):**
```bash
sudo wg-quick up /path/to/wg0.conf
# disconnect: sudo wg-quick down /path/to/wg0.conf
```
**Linux (persistent, starts on boot):**
```bash
sudo cp wg0.conf /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0
```
**Windows / macOS / Android / iOS:**
Import the config file into the WireGuard app, then toggle the tunnel on.
---
## Step 6 — Verify
```bash
ping 10.10.10.1 # Proxmox host
ping 10.10.1.137 # monitoring VM
# Proxmox web UI: https://10.10.10.1:8006
# Grafana: http://10.10.1.137:3000
```
---
## Removing a client
1. Delete the peer entry from `wireguard_peers` in `group_vars/proxmox_hosts/vars.yml`
2. Run `ansible-playbook playbooks/host.yml`
3. The client's public key is removed from the server — existing sessions drop immediately
+2
View File
@@ -0,0 +1,2 @@
files/homelab_ed25519
files/claude-code_ed25519
+64
View File
@@ -0,0 +1,64 @@
---
- name: Install Claude Code dependencies
community.general.pacman:
name:
- nodejs
- npm
- ansible
- python-pip
- tmux
- git
state: present
update_cache: true
- name: Install Claude Code CLI
community.general.npm:
name: "@anthropic-ai/claude-code"
global: true
state: present
- name: Deploy homelab SSH private key
ansible.builtin.copy:
src: homelab_ed25519
dest: /home/{{ admin_user }}/.ssh/homelab_ed25519
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: "0600"
- name: Deploy claude-code SFTP private key
ansible.builtin.copy:
src: claude-code_ed25519
dest: /home/{{ admin_user }}/.ssh/claude-code_ed25519
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: "0600"
- name: Write SSH client config
ansible.builtin.template:
src: ssh_config.j2
dest: /home/{{ admin_user }}/.ssh/config
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: "0600"
- name: Write tmux config
ansible.builtin.copy:
dest: /home/{{ admin_user }}/.tmux.conf
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: "0644"
content: |
set -g default-terminal "screen-256color"
set -g history-limit 50000
set -g mouse on
set -g base-index 1
set -g escape-time 10
bind r source-file ~/.tmux.conf \; display "Reloaded"
- name: Clone homelab repo
ansible.builtin.git:
repo: "http://10.10.1.125:3000/adyrem/homelab.git"
dest: /home/{{ admin_user }}/dev/homelab
version: main
become_user: "{{ admin_user }}"
become: true
@@ -0,0 +1,44 @@
# Proxmox host — jump host only, no shell
Host proxmox
HostName 192.168.1.10
Port 2222
User adyrem
IdentityFile ~/.ssh/homelab_ed25519
StrictHostKeyChecking accept-new
# Proxmox SFTP — write files to host workspace
Host proxmox-sftp
HostName 192.168.1.10
Port 2222
User claude-code
IdentityFile ~/.ssh/claude-code_ed25519
StrictHostKeyChecking accept-new
# Infra VMs — via ProxyJump through Proxmox
Host gitea-vm
HostName 10.10.1.125
User adyrem
ProxyJump proxmox
IdentityFile ~/.ssh/homelab_ed25519
StrictHostKeyChecking accept-new
Host monitoring-vm
HostName 10.10.1.137
User adyrem
ProxyJump proxmox
IdentityFile ~/.ssh/homelab_ed25519
StrictHostKeyChecking accept-new
Host pihole-ct
HostName 10.10.1.2
User root
ProxyJump proxmox
IdentityFile ~/.ssh/homelab_ed25519
StrictHostKeyChecking accept-new
Host traefik-ct
HostName 10.10.1.3
User root
ProxyJump proxmox
IdentityFile ~/.ssh/homelab_ed25519
StrictHostKeyChecking accept-new
+20
View File
@@ -16,6 +16,26 @@
key: "{{ admin_ssh_key }}"
state: present
- name: Configure static IP via NetworkManager
ansible.builtin.shell: |
CURRENT=$(nmcli -t -f IP4.ADDRESS dev show {{ network_interface }} 2>/dev/null | cut -d: -f2)
EXPECTED="{{ ansible_host }}/{{ network_prefix }}"
if [ "$CURRENT" != "$EXPECTED" ]; then
CONN=$(nmcli -t -f NAME,DEVICE con show | grep ":{{ network_interface }}$" | cut -d: -f1 | head -1)
nmcli con mod "$CONN" \
ipv4.method manual \
ipv4.addresses "$EXPECTED" \
ipv4.gateway {{ network_gateway }} \
ipv4.dns {{ network_dns }}
nmcli con up "$CONN"
echo changed
else
echo ok
fi
register: _net
changed_when: "'changed' in _net.stdout"
when: network_gateway is defined
- name: Install base packages
community.general.pacman:
name:
+1
View File
@@ -1,6 +1,7 @@
---
gitea_http_port: 3000
gitea_domain: "{{ ansible_host }}"
gitea_root_url: "https://adyrem.duckdns.org/git/"
gitea_db_name: gitea
gitea_db_user: gitea
gitea_db_password: "{{ vault_gitea_db_password }}"
+1 -1
View File
@@ -6,7 +6,7 @@ RUN_MODE = prod
DOMAIN = {{ gitea_domain }}
HTTP_ADDR = 0.0.0.0
HTTP_PORT = {{ gitea_http_port }}
ROOT_URL = http://{{ gitea_domain }}:{{ gitea_http_port }}/
ROOT_URL = {{ gitea_root_url }}
SSH_DOMAIN = {{ gitea_domain }}
SSH_PORT = 22
START_SSH_SERVER = false
+25
View File
@@ -0,0 +1,25 @@
---
jellyfin_version: "10.10.7"
qbittorrent_version: "5.1.4"
radarr_version: "6.0.4"
sonarr_version: "4.0.16"
prowlarr_version: "2.3.0"
flaresolverr_version: "v3.3.21"
jellyseerr_version: "2.5.2"
jellyfin_timezone: "Europe/Zurich"
jellyfin_puid: "1000"
jellyfin_pgid: "1000"
nvidia_driver_version: "580.159.03"
qbittorrent_webui_password: "{{ vault_qbittorrent_password }}"
jellyfin_config_dirs:
- /containers/jellyfin/config
- /containers/qbittorrent/config
- /containers/radarr/config
- /containers/sonarr/config
- /containers/prowlarr/config
- /containers/flaresolverr/config
- /containers/jellyseerr/config
+6
View File
@@ -0,0 +1,6 @@
---
- name: Restart jellyfin stack
ansible.builtin.systemd:
name: jellyfin-stack
state: restarted
daemon_reload: true
@@ -0,0 +1,567 @@
---
# --- Media library directories ---
- name: Create media library directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ jellyfin_puid }}"
group: "{{ jellyfin_pgid }}"
mode: "0755"
loop:
- /media/movies
- /media/tv
- /media/transcodes
# --- qBittorrent: pre-seed credentials on fresh install ---
- name: Check if qBittorrent config exists
ansible.builtin.stat:
path: /containers/qbittorrent/config/qBittorrent/qBittorrent.conf
register: _qbt_conf
- name: Deploy qBittorrent credential seed script
ansible.builtin.template:
src: qbittorrent_seed.py.j2
dest: /tmp/qbt_seed.py
mode: "0700"
when: not _qbt_conf.stat.exists
- name: Pre-seed qBittorrent credentials
ansible.builtin.command:
cmd: python3 /tmp/qbt_seed.py
when: not _qbt_conf.stat.exists
# --- Wait for services to be ready ---
- name: Read Radarr config
ansible.builtin.slurp:
src: /containers/radarr/config/config.xml
register: _radarr_config
- name: Read Sonarr config
ansible.builtin.slurp:
src: /containers/sonarr/config/config.xml
register: _sonarr_config
- name: Read Prowlarr config
ansible.builtin.slurp:
src: /containers/prowlarr/config/config.xml
register: _prowlarr_config
- name: Set API key facts
ansible.builtin.set_fact:
_radarr_key: "{{ (_radarr_config.content | b64decode | regex_search('<ApiKey>(.*?)</ApiKey>', '\\1'))[0] }}"
_sonarr_key: "{{ (_sonarr_config.content | b64decode | regex_search('<ApiKey>(.*?)</ApiKey>', '\\1'))[0] }}"
_prowlarr_key: "{{ (_prowlarr_config.content | b64decode | regex_search('<ApiKey>(.*?)</ApiKey>', '\\1'))[0] }}"
- name: Wait for Radarr
ansible.builtin.uri:
url: "http://localhost:7878/api/v3/system/status"
headers:
X-Api-Key: "{{ _radarr_key }}"
register: _r
until: _r.status == 200
retries: 12
delay: 5
- name: Wait for Sonarr
ansible.builtin.uri:
url: "http://localhost:8989/api/v3/system/status"
headers:
X-Api-Key: "{{ _sonarr_key }}"
register: _r
until: _r.status == 200
retries: 12
delay: 5
- name: Wait for Prowlarr
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/system/status"
headers:
X-Api-Key: "{{ _prowlarr_key }}"
register: _r
until: _r.status == 200
retries: 12
delay: 5
# --- Prowlarr: fix TPB season search ---
# apibay.org fulltext indexes "s05e01" as a single token, not "s05". When Prowlarr
# appends the season number to the query (the.boys.s05), apibay returns 0 results.
# Removing season/ep from the tv-search capability makes Prowlarr search with just
# the show title; Sonarr's parser then filters the results by season/episode.
# The file is locked read-only so Prowlarr cannot revert it on definition refresh.
- name: Wait for Prowlarr to write TPB definition file
ansible.builtin.stat:
path: /containers/prowlarr/config/Definitions/thepiratebay.yml
register: _tpb_def
until: _tpb_def.stat.exists
retries: 12
delay: 5
- name: Fix TPB tv-search to not append season to query
ansible.builtin.lineinfile:
path: /containers/prowlarr/config/Definitions/thepiratebay.yml
regexp: '^\s+tv-search: \[q, season, ep\]'
line: ' tv-search: [q]'
register: _tpb_fix
- name: Lock TPB definition read-only to prevent Prowlarr reverting the fix
ansible.builtin.file:
path: /containers/prowlarr/config/Definitions/thepiratebay.yml
mode: "0444"
- name: Restart Prowlarr to apply TPB definition fix
ansible.builtin.command:
cmd: docker restart prowlarr
changed_when: true
when: _tpb_fix.changed
- name: Wait for Prowlarr after restart
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/system/status"
headers:
X-Api-Key: "{{ _prowlarr_key }}"
register: _r
until: _r.status == 200
retries: 12
delay: 5
when: _tpb_fix.changed
# --- Radarr: download client ---
- name: Get Radarr download clients
ansible.builtin.uri:
url: "http://localhost:7878/api/v3/downloadclient"
headers:
X-Api-Key: "{{ _radarr_key }}"
register: _radarr_dc
- name: Add qBittorrent to Radarr
ansible.builtin.uri:
url: "http://localhost:7878/api/v3/downloadclient"
method: POST
headers:
X-Api-Key: "{{ _radarr_key }}"
body_format: json
body:
name: qBittorrent
enable: true
protocol: torrent
priority: 1
implementation: QBittorrent
configContract: QBittorrentSettings
fields:
- name: host
value: qbittorrent
- name: port
value: 5080
- name: useSsl
value: false
- name: username
value: admin
- name: password
value: "{{ qbittorrent_webui_password }}"
- name: movieCategory
value: radarr
tags: []
status_code: 201
when: _radarr_dc.json | selectattr('name', 'equalto', 'qBittorrent') | list | length == 0
# --- Radarr: root folder ---
- name: Get Radarr root folders
ansible.builtin.uri:
url: "http://localhost:7878/api/v3/rootfolder"
headers:
X-Api-Key: "{{ _radarr_key }}"
register: _radarr_rf
- name: Add movies root folder to Radarr
ansible.builtin.uri:
url: "http://localhost:7878/api/v3/rootfolder"
method: POST
headers:
X-Api-Key: "{{ _radarr_key }}"
body_format: json
body:
path: /downloads/movies
status_code: 201
when: _radarr_rf.json | selectattr('path', 'equalto', '/downloads/movies') | list | length == 0
# --- Sonarr: download client ---
- name: Get Sonarr download clients
ansible.builtin.uri:
url: "http://localhost:8989/api/v3/downloadclient"
headers:
X-Api-Key: "{{ _sonarr_key }}"
register: _sonarr_dc
- name: Add qBittorrent to Sonarr
ansible.builtin.uri:
url: "http://localhost:8989/api/v3/downloadclient"
method: POST
headers:
X-Api-Key: "{{ _sonarr_key }}"
body_format: json
body:
name: qBittorrent
enable: true
protocol: torrent
priority: 1
implementation: QBittorrent
configContract: QBittorrentSettings
fields:
- name: host
value: qbittorrent
- name: port
value: 5080
- name: useSsl
value: false
- name: username
value: admin
- name: password
value: "{{ qbittorrent_webui_password }}"
- name: tvCategory
value: sonarr
tags: []
status_code: 201
when: _sonarr_dc.json | selectattr('name', 'equalto', 'qBittorrent') | list | length == 0
# --- Sonarr: root folder ---
- name: Get Sonarr root folders
ansible.builtin.uri:
url: "http://localhost:8989/api/v3/rootfolder"
headers:
X-Api-Key: "{{ _sonarr_key }}"
register: _sonarr_rf
- name: Add TV root folder to Sonarr
ansible.builtin.uri:
url: "http://localhost:8989/api/v3/rootfolder"
method: POST
headers:
X-Api-Key: "{{ _sonarr_key }}"
body_format: json
body:
path: /downloads/tv
status_code: 201
when: _sonarr_rf.json | selectattr('path', 'equalto', '/downloads/tv') | list | length == 0
# --- Prowlarr: apps ---
- name: Get Prowlarr applications
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/applications"
headers:
X-Api-Key: "{{ _prowlarr_key }}"
register: _prowlarr_apps
- name: Add Radarr to Prowlarr
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/applications"
method: POST
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: Radarr
syncLevel: fullSync
implementation: Radarr
configContract: RadarrSettings
fields:
- name: prowlarrUrl
value: "http://prowlarr:9696"
- name: baseUrl
value: "http://radarr:7878"
- name: apiKey
value: "{{ _radarr_key }}"
- name: syncCategories
value: [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060]
tags: []
status_code: 201
when: _prowlarr_apps.json | selectattr('name', 'equalto', 'Radarr') | list | length == 0
- name: Add Sonarr to Prowlarr
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/applications"
method: POST
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: Sonarr
syncLevel: fullSync
implementation: Sonarr
configContract: SonarrSettings
fields:
- name: prowlarrUrl
value: "http://prowlarr:9696"
- name: baseUrl
value: "http://sonarr:8989"
- name: apiKey
value: "{{ _sonarr_key }}"
- name: syncCategories
value: [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5060, 5070, 5080]
tags: []
status_code: 201
when: _prowlarr_apps.json | selectattr('name', 'equalto', 'Sonarr') | list | length == 0
# --- Prowlarr: FlareSolverr proxy ---
- name: Get Prowlarr indexer proxies
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexerproxy"
headers:
X-Api-Key: "{{ _prowlarr_key }}"
register: _prowlarr_proxies
- name: Add FlareSolverr proxy to Prowlarr
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexerproxy"
method: POST
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: FlareSolverr
implementation: FlareSolverr
configContract: FlareSolverrSettings
tags: [1]
fields:
- name: host
value: "http://flaresolverr:8191"
- name: requestTimeout
value: 60
status_code: 201
when: _prowlarr_proxies.json | selectattr('name', 'equalto', 'FlareSolverr') | list | length == 0
# --- Prowlarr: indexers ---
- name: Get Prowlarr indexers
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexer"
headers:
X-Api-Key: "{{ _prowlarr_key }}"
register: _prowlarr_indexers
- name: Add YTS indexer
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexer"
method: POST
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: YTS
enable: true
appProfileId: 1
priority: 25
implementation: Cardigann
configContract: CardigannSettings
tags: []
fields:
- name: definitionFile
value: yts
- name: baseSettings.limitsUnit
value: 0
- name: torrentBaseSettings.preferMagnetUrl
value: false
- name: apiurl
value: movies-api.accel.li
status_code: 201
when: _prowlarr_indexers.json | selectattr('name', 'equalto', 'YTS') | list | length == 0
- name: Add The Pirate Bay indexer
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexer"
method: POST
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: The Pirate Bay
enable: true
appProfileId: 1
priority: 25
implementation: Cardigann
configContract: CardigannSettings
tags: []
fields:
- name: definitionFile
value: thepiratebay
- name: baseSettings.limitsUnit
value: 0
- name: torrentBaseSettings.preferMagnetUrl
value: false
- name: apiurl
value: apibay.org
status_code: 201
when: _prowlarr_indexers.json | selectattr('name', 'equalto', 'The Pirate Bay') | list | length == 0
- name: Add EZTV indexer
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexer"
method: POST
timeout: 90
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: EZTV
enable: true
appProfileId: 1
priority: 25
implementation: Cardigann
configContract: CardigannSettings
tags: [1]
fields:
- name: definitionFile
value: eztv
- name: baseSettings.limitsUnit
value: 0
- name: torrentBaseSettings.preferMagnetUrl
value: false
status_code: 201
when: _prowlarr_indexers.json | selectattr('name', 'equalto', 'EZTV') | list | length == 0
ignore_errors: true
- name: Add 1337x indexer
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/indexer"
method: POST
timeout: 90
headers:
X-Api-Key: "{{ _prowlarr_key }}"
body_format: json
body:
name: 1337x
enable: true
appProfileId: 1
priority: 25
implementation: Cardigann
configContract: CardigannSettings
tags: [1]
fields:
- name: definitionFile
value: 1337x
- name: baseSettings.limitsUnit
value: 0
- name: torrentBaseSettings.preferMagnetUrl
value: false
- name: downloadlink
value: 0
- name: downloadlink2
value: 1
- name: sort
value: 2
- name: type
value: 1
status_code: 201
when: _prowlarr_indexers.json | selectattr('name', 'equalto', '1337x') | list | length == 0
ignore_errors: true
- name: Sync Prowlarr indexers to apps
ansible.builtin.uri:
url: "http://localhost:9696/api/v1/applications/syncindexers"
method: GET
headers:
X-Api-Key: "{{ _prowlarr_key }}"
status_code: [200, 202]
ignore_errors: true
# --- Jellyseerr: connect Sonarr and Radarr ---
- name: Check if Jellyseerr is initialized
ansible.builtin.stat:
path: /containers/jellyseerr/config/settings.json
register: _js_settings
- name: Read Jellyseerr settings
ansible.builtin.slurp:
src: /containers/jellyseerr/config/settings.json
register: _js_raw
when: _js_settings.stat.exists
- name: Set Jellyseerr API key fact
ansible.builtin.set_fact:
_js_key: "{{ (_js_raw.content | b64decode | from_json).main.apiKey }}"
when: _js_settings.stat.exists
- name: Get Jellyseerr Sonarr servers
ansible.builtin.uri:
url: "http://localhost:5055/api/v1/settings/sonarr"
headers:
X-Api-Key: "{{ _js_key }}"
register: _js_sonarr
when: _js_settings.stat.exists
- name: Configure Sonarr in Jellyseerr
ansible.builtin.uri:
url: "http://localhost:5055/api/v1/settings/sonarr/{{ '0' if (_js_sonarr.json | length == 0) else (_js_sonarr.json[0].id | string) }}"
method: "{{ 'POST' if (_js_sonarr.json | length == 0) else 'PUT' }}"
headers:
X-Api-Key: "{{ _js_key }}"
body_format: json
body:
name: sonarr
hostname: sonarr
port: 8989
apiKey: "{{ _sonarr_key }}"
useSsl: false
baseUrl: ""
activeProfileId: 4
activeProfileName: HD-1080p
activeDirectory: /downloads/tv
tags: []
animeTags: []
is4k: false
isDefault: true
enableSeasonFolders: true
syncEnabled: false
preventSearch: false
tagRequests: false
status_code: [200, 201]
when: >
_js_settings.stat.exists and
(_js_sonarr.json | length == 0 or not _js_sonarr.json[0].isDefault)
- name: Get Jellyseerr Radarr servers
ansible.builtin.uri:
url: "http://localhost:5055/api/v1/settings/radarr"
headers:
X-Api-Key: "{{ _js_key }}"
register: _js_radarr
when: _js_settings.stat.exists
- name: Configure Radarr in Jellyseerr
ansible.builtin.uri:
url: "http://localhost:5055/api/v1/settings/radarr/{{ '0' if (_js_radarr.json | length == 0) else (_js_radarr.json[0].id | string) }}"
method: "{{ 'POST' if (_js_radarr.json | length == 0) else 'PUT' }}"
headers:
X-Api-Key: "{{ _js_key }}"
body_format: json
body:
name: radarr
hostname: radarr
port: 7878
apiKey: "{{ _radarr_key }}"
useSsl: false
baseUrl: ""
activeProfileId: 4
activeProfileName: HD-1080p
activeDirectory: /downloads/movies
is4k: false
minimumAvailability: released
tags: []
isDefault: true
syncEnabled: false
preventSearch: false
tagRequests: false
status_code: [200, 201]
when: >
_js_settings.stat.exists and
(_js_radarr.json | length == 0 or not _js_radarr.json[0].isDefault)
+157
View File
@@ -0,0 +1,157 @@
---
# --- System setup ---
- name: Set timezone
community.general.timezone:
name: "{{ jellyfin_timezone }}"
- name: Grant adyrem passwordless sudo
ansible.builtin.copy:
dest: /etc/sudoers.d/adyrem
content: "adyrem ALL=(ALL:ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
- name: Install base packages
ansible.builtin.apt:
name:
- curl
- ca-certificates
- gnupg
- apt-transport-https
state: present
update_cache: true
# --- Docker ---
- name: Add Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /usr/share/keyrings/docker.asc
mode: "0644"
- name: Add Docker apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch=amd64 signed-by=/usr/share/keyrings/docker.asc]
https://download.docker.com/linux/debian bookworm stable
filename: docker
state: present
- name: Install Docker and compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Enable and start Docker
ansible.builtin.systemd:
name: docker
enabled: true
state: started
# --- NVIDIA container toolkit ---
- name: Add NVIDIA container toolkit GPG key
ansible.builtin.get_url:
url: https://nvidia.github.io/libnvidia-container/gpgkey
dest: /usr/share/keyrings/nvidia-container-toolkit.asc
mode: "0644"
- name: Add NVIDIA container toolkit apt repository
ansible.builtin.get_url:
url: https://nvidia.github.io/libnvidia-container/stable/deb/amd64/Packages
dest: /tmp/nvidia-ct-packages
mode: "0644"
check_mode: false
changed_when: false
- name: Write nvidia-container-toolkit sources list
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/nvidia-container-toolkit.list
content: |
deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit.asc] https://nvidia.github.io/libnvidia-container/stable/deb/amd64 /
mode: "0644"
- name: Install nvidia-container-toolkit
ansible.builtin.apt:
name: nvidia-container-toolkit
state: present
update_cache: true
- name: Configure Docker to use NVIDIA runtime
ansible.builtin.command:
cmd: nvidia-ctk runtime configure --runtime=docker
changed_when: false
- name: Restart Docker to apply NVIDIA runtime
ansible.builtin.systemd:
name: docker
state: restarted
# --- NVIDIA userspace libraries ---
# The LXC has /dev/nvidia* bind-mounted from the host.
# The host's NVIDIA driver version must match what we install here.
- name: Check if NVIDIA userspace libraries are installed
ansible.builtin.stat:
path: /usr/lib/x86_64-linux-gnu/libcuda.so.{{ nvidia_driver_version }}
register: _nvidia_libs
- name: Download NVIDIA installer on LXC
ansible.builtin.get_url:
url: "https://download.nvidia.com/XFree86/Linux-x86_64/{{ nvidia_driver_version }}/NVIDIA-Linux-x86_64-{{ nvidia_driver_version }}.run"
dest: /tmp/nvidia.run
mode: "0755"
when: not _nvidia_libs.stat.exists
- name: Install NVIDIA userspace libraries (no kernel module)
ansible.builtin.command:
cmd: /tmp/nvidia.run --silent --no-kernel-module --no-drm
creates: /usr/lib/x86_64-linux-gnu/libcuda.so.{{ nvidia_driver_version }}
when: not _nvidia_libs.stat.exists
# --- Config directories and compose file ---
- name: Create container config directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop: "{{ jellyfin_config_dirs }}"
- name: Create stack directory
ansible.builtin.file:
path: /opt/jellyfin
state: directory
mode: "0755"
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: /opt/jellyfin/docker-compose.yml
mode: "0644"
notify: Restart jellyfin stack
- name: Deploy systemd service
ansible.builtin.template:
src: jellyfin-stack.service.j2
dest: /etc/systemd/system/jellyfin-stack.service
mode: "0644"
notify: Restart jellyfin stack
- name: Enable and start jellyfin stack service
ansible.builtin.systemd:
name: jellyfin-stack
enabled: true
state: started
daemon_reload: true
- name: Configure services
ansible.builtin.include_tasks: configure_services.yml
@@ -0,0 +1,96 @@
name: jellyfin
services:
jellyfin:
image: jellyfin/jellyfin:{{ jellyfin_version }}
container_name: jellyfin
runtime: nvidia
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
- TZ={{ jellyfin_timezone }}
volumes:
- /containers/jellyfin/config:/config
- /media:/media
- /media/transcodes:/cache
ports:
- "8096:8096"
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:{{ qbittorrent_version }}
container_name: qbittorrent
environment:
- PUID={{ jellyfin_puid }}
- PGID={{ jellyfin_pgid }}
- TZ={{ jellyfin_timezone }}
- WEBUI_PORT=5080
volumes:
- /containers/qbittorrent/config:/config
- /media:/downloads
ports:
- "5080:5080"
- "6881:6881"
- "6881:6881/udp"
restart: unless-stopped
radarr:
image: lscr.io/linuxserver/radarr:{{ radarr_version }}
container_name: radarr
environment:
- PUID={{ jellyfin_puid }}
- PGID={{ jellyfin_pgid }}
- TZ={{ jellyfin_timezone }}
volumes:
- /containers/radarr/config:/config
- /media:/downloads
ports:
- "7878:7878"
restart: unless-stopped
sonarr:
image: lscr.io/linuxserver/sonarr:{{ sonarr_version }}
container_name: sonarr
environment:
- PUID={{ jellyfin_puid }}
- PGID={{ jellyfin_pgid }}
- TZ={{ jellyfin_timezone }}
volumes:
- /containers/sonarr/config:/config
- /media:/downloads
ports:
- "8989:8989"
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:{{ prowlarr_version }}
container_name: prowlarr
environment:
- PUID={{ jellyfin_puid }}
- PGID={{ jellyfin_pgid }}
- TZ={{ jellyfin_timezone }}
volumes:
- /containers/prowlarr/config:/config
ports:
- "9696:9696"
restart: unless-stopped
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:{{ flaresolverr_version }}
container_name: flaresolverr
environment:
- LOG_LEVEL=info
- TZ={{ jellyfin_timezone }}
ports:
- "8191:8191"
restart: unless-stopped
jellyseerr:
image: ghcr.io/fallenbagel/jellyseerr:{{ jellyseerr_version }}
container_name: jellyseerr
environment:
- TZ={{ jellyfin_timezone }}
volumes:
- /containers/jellyseerr/config:/app/config
ports:
- "5055:5055"
restart: unless-stopped
@@ -0,0 +1,15 @@
[Unit]
Description=Jellyfin media stack
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/jellyfin
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import hashlib, os, base64
password = "{{ qbittorrent_webui_password }}"
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 100000)
pw_hash = "@ByteArray(" + base64.b64encode(salt).decode() + ":" + base64.b64encode(dk).decode() + ")"
os.makedirs("/containers/qbittorrent/config/qBittorrent", exist_ok=True)
with open("/containers/qbittorrent/config/qBittorrent/qBittorrent.conf", "w") as f:
f.write("[Preferences]\n")
f.write('WebUI\\Password_PBKDF2="{}"\n'.format(pw_hash))
f.write("WebUI\\Port=5080\n")
f.write("WebUI\\Username=admin\n")
f.write("WebUI\\LocalHostAuth=false\n")
f.write("WebUI\\ServerDomains=*\n")
f.write("WebUI\\Address=*\n")
+21
View File
@@ -0,0 +1,21 @@
---
pihole_password: "{{ vault_pihole_password }}"
pihole_dns_upstream1: "8.8.8.8"
pihole_dns_upstream2: "8.8.4.4"
pihole_local_domain: "homelab"
pihole_interface: "eth0"
pihole_dns_hosts:
- { ip: "10.10.1.2", host: "pihole.homelab" }
- { ip: "10.10.1.3", host: "traefik.homelab" }
- { ip: "10.10.1.125", host: "gitea.homelab" }
- { ip: "10.10.1.3", host: "grafana.homelab" }
- { ip: "10.10.1.3", host: "proxmox.homelab" }
- { ip: "10.10.1.3", host: "adyrem.duckdns.org" }
- { ip: "10.10.1.3", host: "anki.homelab" }
- { ip: "10.10.1.3", host: "devbox.homelab" }
- { ip: "10.10.1.3", host: "jellyfin.homelab" }
- { ip: "10.10.1.3", host: "jellyseerr.homelab" }
- { ip: "10.10.1.3", host: "radarr.homelab" }
- { ip: "10.10.1.3", host: "sonarr.homelab" }
- { ip: "10.10.1.3", host: "prowlarr.homelab" }
- { ip: "10.10.1.3", host: "qbittorrent.homelab" }
+10
View File
@@ -0,0 +1,10 @@
---
- name: Restart pihole-FTL
ansible.builtin.service:
name: pihole-FTL
state: restarted
- name: Update pihole hosts
ansible.builtin.command:
cmd: python3 /tmp/pihole_update_hosts.py
notify: Restart pihole-FTL
+58
View File
@@ -0,0 +1,58 @@
---
- name: Install Pi-hole dependencies
ansible.builtin.apt:
name:
- curl
- git
- libcap2-bin
- dns-root-data
state: present
update_cache: true
- name: Create Pi-hole config directory
ansible.builtin.file:
path: /etc/pihole
state: directory
mode: "0755"
- name: Write Pi-hole setup vars
ansible.builtin.template:
src: setupVars.conf.j2
dest: /etc/pihole/setupVars.conf
mode: "0644"
- name: Check if Pi-hole is already installed
ansible.builtin.stat:
path: /usr/local/bin/pihole
register: pihole_binary
- name: Download Pi-hole installer
ansible.builtin.get_url:
url: https://install.pi-hole.net
dest: /tmp/pihole-install.sh
mode: "0755"
when: not pihole_binary.stat.exists
- name: Run Pi-hole unattended installer
ansible.builtin.command:
cmd: /tmp/pihole-install.sh --unattended
creates: /usr/local/bin/pihole
when: not pihole_binary.stat.exists
- name: Set Pi-hole web password
ansible.builtin.command:
cmd: /usr/local/bin/pihole setpassword "{{ pihole_password }}"
changed_when: true
- name: Set local DNS hosts in pihole.toml
ansible.builtin.template:
src: update_hosts.py.j2
dest: /tmp/pihole_update_hosts.py
mode: "0755"
notify: Update pihole hosts
- name: Ensure pihole-FTL is running
ansible.builtin.service:
name: pihole-FTL
state: started
enabled: true
@@ -0,0 +1,15 @@
PIHOLE_INTERFACE={{ pihole_interface }}
PIHOLE_DNS_1={{ pihole_dns_upstream1 }}
PIHOLE_DNS_2={{ pihole_dns_upstream2 }}
QUERY_LOGGING=true
INSTALL_WEB_SERVER=true
INSTALL_WEB_INTERFACE=true
LIGHTTPD_ENABLED=true
CACHE_SIZE=10000
DNS_FQDN_REQUIRED=false
DNS_BOGUS_PRIV=true
DNSMASQ_LISTENING=local
WEBPASSWORD=
BLOCKING_ENABLED=true
PIHOLE_DOMAIN={{ pihole_local_domain }}
IPV4_ADDRESS=10.10.1.2/24
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import re
hosts = [
{% for h in pihole_dns_hosts %}
"{{ h.ip }} {{ h.host }}",
{% endfor %}
]
hosts_str = "[\n" + "".join(' "' + h + '",\n' for h in hosts) + " ]"
with open("/etc/pihole/pihole.toml", "r") as f:
content = f.read()
# Only replace the first hosts= (DNS custom records section, not DHCP leases)
content = re.sub(
r"( *hosts\s*=\s*)\[.*?\]",
r"\g<1>" + hosts_str,
content,
count=1,
flags=re.DOTALL,
)
with open("/etc/pihole/pihole.toml", "w") as f:
f.write(content)
@@ -0,0 +1 @@
ssh_port: 2222
@@ -0,0 +1,8 @@
[Unit]
Description=DuckDNS IP update
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/duckdns-update.sh
@@ -0,0 +1,9 @@
[Unit]
Description=DuckDNS IP update — every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Ensure Proxmox host firewall rules exist via pvesh. Idempotent."""
import json
import subprocess
NODE = subprocess.run(['hostname'], capture_output=True, text=True).stdout.strip()
DESIRED = [
{'proto': 'tcp', 'source': '192.168.1.0/24', 'dport': '2222'},
{'proto': 'tcp', 'source': '10.10.10.0/24', 'dport': '2222'},
{'proto': 'tcp', 'source': '192.168.1.0/24', 'dport': '8006'},
{'proto': 'tcp', 'source': '10.10.10.0/24', 'dport': '8006'},
{'proto': 'tcp', 'source': '10.10.1.3', 'dport': '8006'},
{'proto': 'tcp', 'source': '192.168.1.0/24', 'dport': '3128'},
{'proto': 'udp', 'source': '10.10.0.0/16', 'dport': '53'},
{'proto': 'tcp', 'source': '10.10.0.0/16', 'dport': '53'},
{'proto': 'udp', 'dport': '51820'},
{'proto': 'icmp'},
]
def pvesh(*args):
return subprocess.run(['pvesh', *args], capture_output=True, text=True)
def get_rules():
r = pvesh('get', f'/nodes/{NODE}/firewall/rules', '--output-format', 'json')
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
return []
def matches(current, desired):
return all(str(current.get(k, '')) == str(v) for k, v in desired.items())
endpoint = f'/nodes/{NODE}/firewall/rules'
current = get_rules()
changed = False
for rule in DESIRED:
if not any(matches(c, rule) for c in current):
args = ['create', endpoint, '--action', 'ACCEPT', '--type', 'in', '--enable', '1']
for k, v in rule.items():
args += [f'--{k}', str(v)]
pvesh(*args)
changed = True
print('changed' if changed else 'ok')
@@ -0,0 +1,14 @@
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted
- name: Restart WireGuard
ansible.builtin.systemd:
name: wg-quick@wg0
state: restarted
- name: Reload Proxmox firewall
ansible.builtin.service:
name: pve-firewall
state: restarted
+224
View File
@@ -0,0 +1,224 @@
# --- Proxmox firewall ---
- name: Ensure Proxmox datacenter firewall is enabled
ansible.builtin.shell: pvesh set /cluster/firewall/options --enable 1
changed_when: false
- name: Ensure host firewall rules
ansible.builtin.script:
cmd: "{{ role_path }}/files/pve_firewall.py"
executable: /usr/bin/python3
register: _pve_fw
changed_when: "'changed' in _pve_fw.stdout"
failed_when: false
# --- Packages ---
- name: Install required packages
ansible.builtin.apt:
name:
- sudo
- unattended-upgrades
- apt-listchanges
- wireguard
state: present
update_cache: true
# --- Admin user ---
- name: Ensure admin user exists
ansible.builtin.user:
name: "{{ admin_user }}"
shell: /bin/bash
groups: sudo
append: true
create_home: true
- name: Authorize admin SSH key
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ admin_ssh_key }}"
state: present
exclusive: true
- name: Grant admin user passwordless sudo
ansible.builtin.copy:
dest: /etc/sudoers.d/{{ admin_user }}
content: "{{ admin_user }} ALL=(ALL:ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: '0440'
validate: visudo -cf %s
# --- claude-code SFTP user ---
- name: Ensure claude-code user exists
ansible.builtin.user:
name: claude-code
shell: /usr/sbin/nologin
home: /home/claude-code
create_home: false
system: false
- name: Chroot base directory — root-owned (OpenSSH requirement)
ansible.builtin.file:
path: /home/claude-code
state: directory
owner: root
group: root
mode: '0755'
- name: .ssh directory for claude-code authorized_keys
ansible.builtin.file:
path: /home/claude-code/.ssh
state: directory
owner: root
group: root
mode: '0755'
- name: Authorize claude-code SSH key
ansible.builtin.copy:
dest: /home/claude-code/.ssh/authorized_keys
content: "{{ claude_code_ssh_key }}\n"
owner: root
group: root
mode: '0644'
- name: Writable workspace inside chroot
ansible.builtin.file:
path: /home/claude-code/workspace
state: directory
owner: claude-code
group: claude-code
mode: '0755'
# --- SSH hardening ---
# Change port in the main config so sshd listens only on ssh_port.
# A drop-in Port directive would add a second port, not replace the default.
- name: Set SSH port
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?Port\s'
line: "Port {{ ssh_port }}"
notify: Restart sshd
- name: Deploy SSH hardening drop-in
ansible.builtin.template:
src: sshd_homelab.conf.j2
dest: /etc/ssh/sshd_config.d/99-homelab.conf
owner: root
group: root
mode: '0600'
notify: Restart sshd
# --- Unattended upgrades ---
- name: Deploy unattended-upgrades config
ansible.builtin.template:
src: 50unattended-upgrades.j2
dest: /etc/apt/apt.conf.d/50unattended-upgrades
owner: root
group: root
mode: '0644'
- name: Enable daily security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
owner: root
group: root
mode: '0644'
# --- DuckDNS dynamic DNS updater ---
- name: Deploy DuckDNS update script
ansible.builtin.template:
src: duckdns-update.sh.j2
dest: /usr/local/bin/duckdns-update.sh
owner: root
group: root
mode: '0700'
- name: Install DuckDNS systemd service
ansible.builtin.copy:
src: duckdns.service
dest: /etc/systemd/system/duckdns.service
owner: root
group: root
mode: '0644'
- name: Install DuckDNS systemd timer
ansible.builtin.copy:
src: duckdns.timer
dest: /etc/systemd/system/duckdns.timer
owner: root
group: root
mode: '0644'
- name: Enable and start DuckDNS timer
ansible.builtin.systemd:
name: duckdns.timer
enabled: true
state: started
daemon_reload: true
# --- WireGuard VPN ---
- name: Enable IP forwarding
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: '1'
sysctl_set: true
reload: true
- name: Generate WireGuard server keypair
ansible.builtin.shell: |
wg genkey > /etc/wireguard/server.key
chmod 600 /etc/wireguard/server.key
wg pubkey < /etc/wireguard/server.key > /etc/wireguard/server.pub
args:
creates: /etc/wireguard/server.key
- name: Read server private key
ansible.builtin.slurp:
src: /etc/wireguard/server.key
register: _wg_server_key
no_log: true
- name: Deploy wg0.conf
ansible.builtin.template:
src: wg0.conf.j2
dest: /etc/wireguard/wg0.conf
owner: root
group: root
mode: '0600'
vars:
wg_server_private_key: "{{ _wg_server_key.content | b64decode | trim }}"
notify: Restart WireGuard
- name: Enable and start WireGuard
ansible.builtin.systemd:
name: wg-quick@wg0
enabled: true
state: started
daemon_reload: true
# --- VM network firewall flags ---
# vmbr2 VMs must have firewall=0 on their network interfaces.
# When firewall=1, Proxmox creates a fwbr bridge; iptables POSTROUTING fires
# at that bridge (OUT=fwbrNNNi0) before the packet reaches vmbr0, so the
# MASQUERADE rule (-o vmbr0) never matches and VMs lose internet access.
- name: Ensure claude-code VM (105) net0 has firewall disabled
ansible.builtin.shell: |
net0=$(qm config 105 | awk '/^net0:/ {print $2}')
if echo "$net0" | grep -q 'firewall=1'; then
qm set 105 --net0 "${net0//,firewall=1/,firewall=0}"
echo changed
fi
register: _vm105_fw
changed_when: "'changed' in _vm105_fw.stdout"
failed_when: false
@@ -0,0 +1,13 @@
// Managed by Ansible — security patches only
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
"origin=Debian,codename=${distro_codename},label=Debian-Security";
};
Unattended-Upgrade::Package-Blacklist {
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "false";
Unattended-Upgrade::Automatic-Reboot "false";
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
result=$(curl -s "https://www.duckdns.org/update?domains={{ duckdns_domain }}&token={{ duckdns_token }}&ip=")
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $result" >> /var/log/duckdns.log
[[ "$result" == "OK" ]] || exit 1
@@ -0,0 +1,11 @@
# Managed by Ansible — do not edit manually
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AuthenticationMethods publickey
Match User claude-code
ForceCommand internal-sftp
ChrootDirectory /home/claude-code
AllowTcpForwarding no
X11Forwarding no
@@ -0,0 +1,15 @@
# Managed by Ansible — do not edit manually
[Interface]
Address = 10.10.10.1/24
ListenPort = {{ wireguard_port }}
PrivateKey = {{ wg_server_private_key }}
PostUp = iptables -I FORWARD -i %i -j ACCEPT; iptables -I FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
{% for peer in wireguard_peers %}
[Peer]
# {{ peer.name }}
PublicKey = {{ peer.public_key }}
AllowedIPs = {{ peer.allowed_ips }}
{% endfor %}
+37
View File
@@ -0,0 +1,37 @@
---
sanoid_datasets:
- dataset: rpool/data
template: vms
recursive: true
- dataset: rpool/ROOT
template: system
recursive: true
sanoid_templates:
vms:
daily: 7
weekly: 4
monthly: 0
hourly: 0
frequently: 0
autosnap: true
autoprune: true
system:
daily: 3
weekly: 2
monthly: 0
hourly: 0
frequently: 0
autosnap: true
autoprune: true
syncoid_jobs:
- src: rpool/data
dst: hdd/backups/rpool-data
recursive: true
- src: rpool/ROOT/pve-1
dst: hdd/backups/pve-root
recursive: false
syncoid_schedule: "*-*-* 02:00:00"
syncoid_randomized_delay: 10min
+12
View File
@@ -0,0 +1,12 @@
---
- name: Restart sanoid timer
ansible.builtin.systemd:
name: sanoid.timer
state: restarted
daemon_reload: true
- name: Restart syncoid timer
ansible.builtin.systemd:
name: syncoid.timer
state: restarted
daemon_reload: true
+47
View File
@@ -0,0 +1,47 @@
---
- name: Install sanoid
ansible.builtin.apt:
name: sanoid
state: present
update_cache: true
- name: Deploy sanoid.conf
ansible.builtin.template:
src: sanoid.conf.j2
dest: /etc/sanoid/sanoid.conf
owner: root
group: root
mode: '0644'
notify: Restart sanoid timer
- name: Deploy syncoid systemd service
ansible.builtin.template:
src: syncoid.service.j2
dest: /etc/systemd/system/syncoid.service
owner: root
group: root
mode: '0644'
notify: Restart syncoid timer
- name: Deploy syncoid systemd timer
ansible.builtin.template:
src: syncoid.timer.j2
dest: /etc/systemd/system/syncoid.timer
owner: root
group: root
mode: '0644'
notify: Restart syncoid timer
- name: Enable and start sanoid timer
ansible.builtin.systemd:
name: sanoid.timer
enabled: true
state: started
daemon_reload: true
- name: Enable and start syncoid timer
ansible.builtin.systemd:
name: syncoid.timer
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,19 @@
{% for entry in sanoid_datasets %}
[{{ entry.dataset }}]
use_template = {{ entry.template }}
{% if entry.recursive %}
recursive = yes
{% endif %}
{% endfor %}
{% for name, t in sanoid_templates.items() %}
[template_{{ name }}]
daily = {{ t.daily }}
weekly = {{ t.weekly }}
monthly = {{ t.monthly }}
hourly = {{ t.hourly }}
frequently = {{ t.frequently }}
autosnap = {{ 'yes' if t.autosnap else 'no' }}
autoprune = {{ 'yes' if t.autoprune else 'no' }}
{% endfor %}
@@ -0,0 +1,9 @@
[Unit]
Description=Syncoid ZFS replication to HDD
After=zfs.target
[Service]
Type=oneshot
{% for job in syncoid_jobs %}
ExecStart=/usr/sbin/syncoid --no-privilege-elevation{% if job.recursive %} --recursive{% endif %} {{ job.src }} {{ job.dst }}
{% endfor %}
@@ -3,8 +3,8 @@ Description=Daily ZFS replication to HDD
After=sanoid.timer
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=10min
OnCalendar={{ syncoid_schedule }}
RandomizedDelaySec={{ syncoid_randomized_delay }}
Persistent=true
[Install]
+59
View File
@@ -0,0 +1,59 @@
---
traefik_version: "3.3.4"
traefik_http_port: 80
traefik_https_port: 443
traefik_dashboard_port: 8080
traefik_log_level: "INFO"
traefik_acme_email: "fanfohcd@gmail.com"
traefik_services:
- name: gitea
host: "gitea.homelab"
backend: "http://10.10.1.125:3000"
public_host: "adyrem.duckdns.org"
public_path: "/git"
strip_prefix: "/git"
- name: devbox
host: "devbox.homelab"
backend: "http://10.10.2.10:4001"
public_host: "adyrem.duckdns.org"
- name: anki
host: "anki.homelab"
backend: "http://10.10.2.10:4002"
public_host: "adyrem.duckdns.org"
public_path: "/Anki"
strip_prefix: "/Anki"
- name: mtg
host: "mtg.homelab"
backend: "http://10.10.2.10:3000"
public_host: "adyrem.duckdns.org"
public_path: "/mtg"
strip_prefix: "/mtg"
- name: grafana
host: "grafana.homelab"
backend: "http://10.10.1.137:3000"
- name: pihole
host: "pihole.homelab"
backend: "http://10.10.1.2:80"
- name: jellyfin
host: "jellyfin.homelab"
backend: "http://10.10.1.50:8096"
- name: jellyseerr
host: "jellyseerr.homelab"
backend: "http://10.10.1.50:5055"
- name: radarr
host: "radarr.homelab"
backend: "http://10.10.1.50:7878"
- name: sonarr
host: "sonarr.homelab"
backend: "http://10.10.1.50:8989"
- name: prowlarr
host: "prowlarr.homelab"
backend: "http://10.10.1.50:9696"
- name: qbittorrent
host: "qbittorrent.homelab"
backend: "http://10.10.1.50:5080"
- name: proxmox
host: "proxmox.homelab"
backend: "https://192.168.1.10:8006"
tls_skip_verify: true
+9
View File
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart traefik
ansible.builtin.systemd:
name: traefik
state: restarted
+130
View File
@@ -0,0 +1,130 @@
---
- name: Install dependencies
ansible.builtin.apt:
name:
- curl
- ca-certificates
state: present
update_cache: true
- name: Create traefik user
ansible.builtin.user:
name: traefik
system: true
shell: /usr/sbin/nologin
home: /etc/traefik
create_home: false
- name: Create traefik directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: traefik
group: traefik
mode: "0750"
loop:
- /etc/traefik
- /etc/traefik/conf.d
- name: Create acme.json for Let's Encrypt certificate storage
ansible.builtin.copy:
dest: /etc/traefik/acme.json
content: ""
owner: traefik
group: traefik
mode: "0600"
force: false
- name: Check installed Traefik version
ansible.builtin.command:
cmd: /usr/local/bin/traefik version
register: traefik_installed_version
changed_when: false
failed_when: false
- name: Download Traefik binary
ansible.builtin.get_url:
url: "https://github.com/traefik/traefik/releases/download/v{{ traefik_version }}/traefik_v{{ traefik_version }}_linux_amd64.tar.gz"
dest: /tmp/traefik.tar.gz
mode: "0644"
when: traefik_version not in (traefik_installed_version.stdout | default(''))
- name: Extract Traefik binary
ansible.builtin.unarchive:
src: /tmp/traefik.tar.gz
dest: /usr/local/bin
include:
- traefik
remote_src: true
mode: "0755"
when: traefik_version not in (traefik_installed_version.stdout | default(''))
notify: Restart traefik
- name: Create traefik certs directory
ansible.builtin.file:
path: /etc/traefik/certs
state: directory
owner: traefik
group: traefik
mode: "0750"
- name: Copy homelab TLS certificate
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../certs/wildcard.homelab.pem"
dest: /etc/traefik/certs/homelab.pem
owner: traefik
group: traefik
mode: "0640"
notify: Restart traefik
- name: Copy homelab TLS key
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../certs/wildcard.homelab-key.pem"
dest: /etc/traefik/certs/homelab-key.pem
owner: traefik
group: traefik
mode: "0640"
notify: Restart traefik
- name: Write Traefik TLS config
ansible.builtin.template:
src: tls.yml.j2
dest: /etc/traefik/conf.d/tls.yml
owner: traefik
group: traefik
mode: "0640"
notify: Restart traefik
- name: Write Traefik static config
ansible.builtin.template:
src: traefik.yml.j2
dest: /etc/traefik/traefik.yml
owner: traefik
group: traefik
mode: "0640"
notify: Restart traefik
- name: Write Traefik dynamic routes config
ansible.builtin.template:
src: routes.yml.j2
dest: /etc/traefik/conf.d/routes.yml
owner: traefik
group: traefik
mode: "0640"
notify: Restart traefik
- name: Write Traefik systemd service
ansible.builtin.template:
src: traefik.service.j2
dest: /etc/systemd/system/traefik.service
mode: "0644"
notify:
- Reload systemd
- Restart traefik
- name: Enable and start Traefik
ansible.builtin.systemd:
name: traefik
state: started
enabled: true
daemon_reload: true
@@ -0,0 +1,72 @@
http:
routers:
{% for svc in traefik_services %}
{{ svc.name }}-internal:
rule: "Host(`{{ svc.host }}`)"
service: {{ svc.name }}
entryPoints:
- web
- websecure
tls: {}
{% if svc.public_host is defined %}
{{ svc.name }}-public-redirect:
rule: "Host(`{{ svc.public_host }}`){% if svc.public_path is defined %} && PathPrefix(`{{ svc.public_path }}`){% endif %}"
service: {{ svc.name }}
entryPoints:
- web
middlewares:
- redirect-https
{{ svc.name }}-public:
rule: "Host(`{{ svc.public_host }}`){% if svc.public_path is defined %} && PathPrefix(`{{ svc.public_path }}`){% endif %}"
service: {{ svc.name }}
entryPoints:
- websecure
tls:
certResolver: letsencrypt
{% if svc.strip_prefix is defined %}
middlewares:
- {{ svc.name }}-strip-prefix
{% endif %}
{% endif %}
{% endfor %}
middlewares:
redirect-https:
redirectScheme:
scheme: https
permanent: true
{% for svc in traefik_services %}
{% if svc.strip_prefix is defined %}
{{ svc.name }}-strip-prefix:
stripPrefix:
prefixes:
- "{{ svc.strip_prefix }}"
{% endif %}
{% endfor %}
services:
{% for svc in traefik_services %}
{{ svc.name }}:
loadBalancer:
servers:
- url: "{{ svc.backend }}"
{% if svc.tls_skip_verify is defined and svc.tls_skip_verify %}
serversTransport: {{ svc.name }}-transport
{% endif %}
{% endfor %}
{% set ns = namespace(has_skip_verify=false) %}
{% for svc in traefik_services %}
{% if svc.tls_skip_verify is defined and svc.tls_skip_verify %}
{% set ns.has_skip_verify = true %}
{% endif %}
{% endfor %}
{% if ns.has_skip_verify %}
serversTransports:
{% for svc in traefik_services %}
{% if svc.tls_skip_verify is defined and svc.tls_skip_verify %}
{{ svc.name }}-transport:
insecureSkipVerify: true
{% endif %}
{% endfor %}
{% endif %}
@@ -0,0 +1,6 @@
tls:
stores:
default:
defaultCertificate:
certFile: /etc/traefik/certs/homelab.pem
keyFile: /etc/traefik/certs/homelab-key.pem
@@ -0,0 +1,18 @@
[Unit]
Description=Traefik reverse proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=traefik
Group=traefik
ExecStart=/usr/local/bin/traefik --configFile=/etc/traefik/traefik.yml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,25 @@
api:
dashboard: true
insecure: true
entryPoints:
web:
address: ":{{ traefik_http_port }}"
websecure:
address: ":{{ traefik_https_port }}"
providers:
file:
directory: /etc/traefik/conf.d
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: {{ traefik_acme_email }}
storage: /etc/traefik/acme.json
httpChallenge:
entryPoint: web
log:
level: {{ traefik_log_level }}
+113
View File
@@ -0,0 +1,113 @@
{
"archinstall-language": "English",
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [
"mkdir -p /home/adyrem/.ssh && chmod 700 /home/adyrem/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdW4rhQFv561bYs8w0/VgR6AFgOLvVsAh6pcjZ/CO2R homelab' > /home/adyrem/.ssh/authorized_keys && chmod 600 /home/adyrem/.ssh/authorized_keys && chown -R adyrem:adyrem /home/adyrem/.ssh",
"nmcli connection modify 'Wired Connection 1' ipv4.method manual ipv4.addresses 10.10.2.10/24 ipv4.gateway 10.10.2.1 ipv4.dns '10.10.1.2' 2>/dev/null; nmcli connection modify $(nmcli -t -f NAME con show | head -1) ipv4.method manual ipv4.addresses 10.10.2.10/24 ipv4.gateway 10.10.2.1 ipv4.dns '10.10.1.2' 2>/dev/null; true"
],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "/dev/vda",
"wipe": true,
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": ["boot", "esp"],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "part-esp",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "name": "@", "mountpoint": "/" },
{ "name": "@home", "mountpoint": "/home" },
{ "name": "@var", "mountpoint": "/var" },
{ "name": "@snapshots", "mountpoint": "/.snapshots" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": ["compress=zstd"],
"mountpoint": null,
"obj_id": "part-root",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 20398997504
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 1074790400
},
"status": "create",
"type": "primary"
}
]
}
]
},
"hostname": "claude-code",
"kernels": ["linux"],
"locale_config": {
"kb_layout": "de_CH-latin1",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": ["https://geo.mirror.pkgbuild.com/$repo/os/$arch"]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"tmux",
"python3",
"python-pip",
"sudo",
"qemu-guest-agent"
],
"parallel_downloads": 5,
"script": "guided",
"services": ["sshd", "NetworkManager", "qemu-guest-agent"],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.3"
}
+248
View File
@@ -0,0 +1,248 @@
{
"annotations": {
"list": [
{
"$$hashKey": "object:75",
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Log Viewer Dashboard for Loki",
"editable": false,
"gnetId": 13639,
"graphTooltip": 0,
"id": 1684514089902080,
"iteration": 1608932746420,
"links": [
{
"$$hashKey": "object:59",
"icon": "bolt",
"includeVars": true,
"keepTime": true,
"tags": [],
"targetBlank": true,
"title": "View In Explore",
"type": "link",
"url": "/explore?orgId=1&left=[\"now-1h\",\"now\",\"Loki\",{\"expr\":\"{job=\\\"$app\\\"}\"},{\"ui\":[true,true,true,\"none\"]}]"
},
{
"$$hashKey": "object:61",
"icon": "external link",
"tags": [],
"targetBlank": true,
"title": "Learn LogQL",
"type": "link",
"url": "https://grafana.com/docs/loki/latest/logql/"
}
],
"panels": [
{
"aliasColors": {},
"bars": true,
"dashLength": 10,
"dashes": false,
"datasource": "P8E80F9AEF21F6940",
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"hiddenSeries": false,
"id": 6,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": false,
"total": false,
"values": false
},
"lines": false,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.0",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(count_over_time({job=\"$app\"} |= \"$search\" [$__interval]))",
"legendFormat": "",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:168",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
},
{
"$$hashKey": "object:169",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"datasource": "P8E80F9AEF21F6940",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"gridPos": {
"h": 25,
"w": 24,
"x": 0,
"y": 3
},
"id": 2,
"maxDataPoints": "",
"options": {
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"expr": "{job=\"$app\"} |= \"$search\" | logfmt",
"hide": false,
"legendFormat": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "",
"transparent": true,
"type": "logs"
}
],
"refresh": false,
"schemaVersion": 26,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"allValue": null,
"current": {},
"datasource": "P8E80F9AEF21F6940",
"definition": "label_values(job)",
"hide": 0,
"includeAll": false,
"label": "App",
"multi": false,
"name": "app",
"options": [],
"query": "label_values(job)",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"current": {
"selected": false,
"text": "",
"value": ""
},
"hide": 0,
"label": "String Match",
"name": "search",
"options": [
{
"selected": true,
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {
"hidden": false,
"refresh_intervals": [
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Logs / App",
"uid": "sadlil-loki-apps-dashboard",
"version": 1
}
File diff suppressed because it is too large Load Diff
+86 -5
View File
@@ -65,9 +65,9 @@ The operator connects from multiple devices (home desktop, laptop, etc.) via Wir
- A dedicated SSH keypair for this project; public key committed to repo, private key kept by operator
- Dedicated non-default SSH port — to be decided during setup
### RDP
- RDP is for VMs only — no RDP on the host
- VM RDP accessible through VPN from outside the network
### Remote Desktop
- RDP is not used — Windows VM uses SPICE via Proxmox web UI (Windows 11 Home N limitation)
- SPICE accessible via VPN by connecting to the Proxmox web UI at port 8006
### Firewall
- Proxmox built-in firewall enabled at datacenter and host level
@@ -183,7 +183,7 @@ The operator connects from multiple devices (home desktop, laptop, etc.) via Wir
1. Clean Windows install + activation
2. Visual Studio install + configuration
- Snapshots sent to HDD ZFS pool; rebuild = `zfs receive` from HDD
- **Access:** RDP via VPN from outside network
- **Access:** SPICE via Proxmox web UI (Windows 11 Home N does not include RDP server)
- **Network:** `desktop-net`, no access to host or other VM networks
- **Monitoring:** Ping/availability check only
@@ -298,7 +298,88 @@ In the event of full hardware loss, the rebuild order is:
---
## 10. Out of Scope (for now)
## 10. Media Stack (Jellyfin)
### Overview
A self-hosted media server and automation stack running in a dedicated LXC container on `infra-net`. All web UIs are internal-only, accessible via VPN or LAN. Port 6881 (BitTorrent peering) is the only internet-facing port.
### Services
| Container | Purpose | Port |
|---|---|---|
| Jellyfin | Media server / streaming | 8096 |
| Jellyseerr | Media request UI | 5055 |
| Radarr | Movie download management | 7878 |
| Sonarr | TV show download management | 8989 |
| Prowlarr | Indexer aggregator (feeds Radarr/Sonarr) | 9696 |
| FlareSolverr | Cloudflare bypass for Prowlarr | 8191 |
| qBittorrent | Torrent client | 5080 (UI), 6881 (peers) |
### LXC Container
- **Host:** Proxmox LXC, ID 106
- **Template:** Debian 12
- **Mode:** Privileged (required for NVIDIA device passthrough)
- **Features:** `nesting=1` (required for Docker)
- **Network:** `vmbr1` (infra-net), static IP `10.10.1.50`
- **Resources:** 4 CPU cores, 4GB RAM
- **GPU passthrough:** NVIDIA GTX 1060 via device node bind-mount (not PCIe passthrough)
- NVIDIA proprietary driver installed on Proxmox host
- Same driver version installed inside the LXC (versions must match exactly)
- Device nodes passed into LXC via `lxc.cgroup2.devices.allow`: `/dev/nvidia0`, `/dev/nvidiactl`, `/dev/nvidia-uvm`, `/dev/nvidia-uvm-tools`
- `nvidia-container-toolkit` installed inside LXC to enable Docker GPU access
- Jellyfin container configured to use NVENC (encoding) and NVDEC (decoding)
### Storage
- **Media library:** ZFS dataset `hdd/media`, bind-mounted into LXC at `/media`
- All containers share this path for downloads and library access
- **Config state:** `/containers/<service>/config` on LXC root dataset (SSD, rpool)
- Directories: `jellyfin`, `qbittorrent`, `radarr`, `sonarr`, `prowlarr`, `flaresolverr`, `jellyseerr`
### Deployment
- All services run via a single Docker Compose file managed by the `jellyfin` Ansible role
- Compose file derived from reference stack (`jellyfin-tools` in repo root) with:
- Jellyfin container added
- Traefik Docker labels removed (Traefik is a standalone LXC, not a Docker container)
- Docker `proxy` network removed; services reach each other by container name within Compose
- All image tags pinned (no `latest`)
- Timezone: `Europe/Zurich`
- Managed as a systemd service so it starts on LXC boot
### Routing (internal-only)
All services accessible at `.homelab` hostnames via Traefik (LXC 104, `10.10.1.3`). No public host — reachable via VPN or LAN only.
| Hostname | Backend |
|---|---|
| `jellyfin.homelab` | `http://10.10.1.50:8096` |
| `jellyseerr.homelab` | `http://10.10.1.50:5055` |
| `radarr.homelab` | `http://10.10.1.50:7878` |
| `sonarr.homelab` | `http://10.10.1.50:8989` |
| `prowlarr.homelab` | `http://10.10.1.50:9696` |
| `qbittorrent.homelab` | `http://10.10.1.50:5080` |
FlareSolverr has no web UI worth routing; Prowlarr reaches it by container name internally.
### DNS
Pi-hole A records: all 6 routed hostnames → `10.10.1.3` (Traefik).
### Port 6881 (BitTorrent Peering)
- **Proxmox DNAT** (in `proxmox/network-interfaces`): TCP+UDP 6881 on `192.168.1.10` → `10.10.1.50:6881`
- **Manual step:** Home router port-forward: 6881 TCP+UDP → `192.168.1.10`
- qBittorrent web UI (5080) remains internal-only
### Ansible
- New role: `jellyfin` — installs Docker, creates config dirs, deploys Compose file, enables systemd service
- New playbook: `jellyfin.yml` — targets the media LXC
- Media LXC added to inventory; included in `site.yml` for common/node_exporter/promtail roles
### NVIDIA Driver Notes
- Driver version on host and in LXC must be identical — this is a hard requirement for NVIDIA device passthrough
- The LXC does **not** need the full NVIDIA driver package (kernel modules already loaded on host); it only needs the userspace libraries (`nvidia-utils` / `libnvidia-compute`)
- `nvidia-smi` inside the LXC is the verification step after setup
- If a Proxmox host kernel update bumps the NVIDIA driver, the LXC libraries must be updated to match before Jellyfin transcoding will work again
---
## 11. Out of Scope (for now)
- External/cloud backups (architecture must not preclude adding later)
- Additional VM distros beyond those listed — template mechanism should make adding easy
+7
View File
@@ -0,0 +1,7 @@
[OPTIONS]
enable: 1
[ALIASES]
lan 192.168.1.0/24
infra_net 10.10.1.0/24
dev_net 10.10.2.0/24
+15
View File
@@ -0,0 +1,15 @@
[OPTIONS]
enable: 1
log_level_in: nolog
log_level_out: nolog
[RULES]
IN ACCEPT -source 192.168.1.0/24 -p tcp -dport 2222
IN ACCEPT -source 10.10.10.0/24 -p tcp -dport 2222
IN ACCEPT -source 192.168.1.0/24 -p tcp -dport 8006
IN ACCEPT -source 10.10.10.0/24 -p tcp -dport 8006
IN ACCEPT -source 192.168.1.0/24 -p tcp -dport 3128
IN ACCEPT -source 10.10.0.0/16 -p udp -dport 53
IN ACCEPT -source 10.10.0.0/16 -p tcp -dport 53
IN ACCEPT -p udp -dport 51820
IN ACCEPT -p icmp
+12
View File
@@ -0,0 +1,12 @@
arch: amd64
cores: 1
features: nesting=1
hostname: pihole
memory: 512
nameserver: 8.8.8.8
net0: name=eth0,bridge=vmbr1,firewall=0,gw=10.10.1.1,hwaddr=BC:24:11:D6:DD:FF,ip=10.10.1.2/24,type=veth
onboot: 1
ostype: debian
rootfs: local-zfs:subvol-103-disk-0,size=4G
swap: 0
unprivileged: 1
+14
View File
@@ -0,0 +1,14 @@
arch: amd64
cores: 1
hostname: traefik
memory: 256
nameserver: 10.10.1.2
net0: name=eth0,bridge=vmbr1,firewall=1,gw=10.10.1.1,hwaddr=BC:24:11:8E:D3:36,ip=10.10.1.3/24,type=veth
onboot: 1
ostype: debian
rootfs: local-zfs:subvol-104-disk-0,size=2G
swap: 0
unprivileged: 1
[pve:pending]
nameserver: 8.8.8.8
+58
View File
@@ -0,0 +1,58 @@
auto lo
iface lo inet loopback
iface nic0 inet manual
auto vmbr0
iface vmbr0 inet static
address 192.168.1.10/24
gateway 192.168.1.1
bridge-ports nic0
bridge-stp off
bridge-fd 0
post-up iptables -t nat -A PREROUTING -i vmbr0 -d 192.168.1.10 -p tcp --dport 6881 -j DNAT --to-destination 10.10.1.50:6881
post-up iptables -t nat -A PREROUTING -i vmbr0 -d 192.168.1.10 -p udp --dport 6881 -j DNAT --to-destination 10.10.1.50:6881
post-down iptables -t nat -D PREROUTING -i vmbr0 -d 192.168.1.10 -p tcp --dport 6881 -j DNAT --to-destination 10.10.1.50:6881
post-down iptables -t nat -D PREROUTING -i vmbr0 -d 192.168.1.10 -p udp --dport 6881 -j DNAT --to-destination 10.10.1.50:6881
post-up iptables -t nat -A PREROUTING -i vmbr0 -d 192.168.1.10 -p tcp --dport 80 -j DNAT --to-destination 10.10.1.3:80
post-up iptables -t nat -A PREROUTING -i vmbr0 -d 192.168.1.10 -p tcp --dport 443 -j DNAT --to-destination 10.10.1.3:443
post-down iptables -t nat -D PREROUTING -i vmbr0 -d 192.168.1.10 -p tcp --dport 80 -j DNAT --to-destination 10.10.1.3:80
post-down iptables -t nat -D PREROUTING -i vmbr0 -d 192.168.1.10 -p tcp --dport 443 -j DNAT --to-destination 10.10.1.3:443
post-up iptables -t nat -A PREROUTING -i vmbr0 -p udp --dport 53 -j DNAT --to-destination 10.10.1.2:53
post-up iptables -t nat -A PREROUTING -i vmbr0 -p tcp --dport 53 -j DNAT --to-destination 10.10.1.2:53
post-up iptables -t nat -A POSTROUTING -p udp -d 10.10.1.2 --dport 53 -j MASQUERADE
post-up iptables -t nat -A POSTROUTING -p tcp -d 10.10.1.2 --dport 53 -j MASQUERADE
post-down iptables -t nat -D PREROUTING -i vmbr0 -p udp --dport 53 -j DNAT --to-destination 10.10.1.2:53
post-down iptables -t nat -D PREROUTING -i vmbr0 -p tcp --dport 53 -j DNAT --to-destination 10.10.1.2:53
post-down iptables -t nat -D POSTROUTING -p udp -d 10.10.1.2 --dport 53 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -p tcp -d 10.10.1.2 --dport 53 -j MASQUERADE
source /etc/network/interfaces.d/*
auto vmbr1
iface vmbr1 inet static
address 10.10.1.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
post-up iptables -t nat -A POSTROUTING -s 10.10.1.0/24 -o vmbr0 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -s 10.10.1.0/24 -o vmbr0 -j MASQUERADE
auto vmbr2
iface vmbr2 inet static
address 10.10.2.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
post-up iptables -t nat -A POSTROUTING -s 10.10.2.0/24 -o vmbr0 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -s 10.10.2.0/24 -o vmbr0 -j MASQUERADE
auto vmbr3
iface vmbr3 inet static
address 10.10.3.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
post-up iptables -t nat -A POSTROUTING -s 10.10.3.0/24 -o vmbr0 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -s 10.10.3.0/24 -o vmbr0 -j MASQUERADE
+16
View File
@@ -0,0 +1,16 @@
agent: enabled=1
bios: ovmf
boot: order=virtio0
cores: 2
cpu: host
efidisk0: local-zfs:vm-100-disk-0,efitype=4m,pre-enrolled-keys=0,size=1M
ide2: none,media=cdrom
memory: 2048
name: gitea
net0: virtio=BC:24:11:1B:9B:6B,bridge=vmbr1
onboot: 1
ostype: l26
smbios1: uuid=9fa7fc5d-6884-48ff-9ee8-498ac7eaaf3f
vga: qxl
virtio0: local-zfs:vm-100-disk-1,discard=on,size=20G
vmgenid: 7b77facc-c7ae-49eb-8bb6-4dd052044281
+17
View File
@@ -0,0 +1,17 @@
agent: enabled=1
bios: ovmf
boot: order=virtio0
cores: 2
cpu: host
efidisk0: local-zfs:vm-101-disk-1,efitype=4m,pre-enrolled-keys=0,size=1M
ide2: none,media=cdrom
memory: 4096
name: monitoring
net0: virtio=BC:24:11:8E:6D:98,bridge=vmbr1
onboot: 1
ostype: l26
serial0: socket
smbios1: uuid=05421e21-f1f0-4d69-b93d-1bbab9fbd28e
vga: std
virtio0: local-zfs:vm-101-disk-0,size=40G
vmgenid: 6fea34d7-e3a0-4734-a1f3-96f5769b5406
+22
View File
@@ -0,0 +1,22 @@
agent: enabled=1
bios: ovmf
boot: order=ide2;scsi0
cores: 8
cpu: host
efidisk0: local-zfs:vm-102-disk-0,efitype=4m,ms-cert=2023k,pre-enrolled-keys=1,size=1M
ide2: local:iso/Win11_25H2_English_x64_v2.iso,media=cdrom,size=605348K
ide3: local:iso/virtio-win.iso,media=cdrom,size=771138K
machine: pc-q35-10.1
memory: 16384
name: windows
net0: virtio=BC:24:11:83:AD:60,bridge=vmbr3,firewall=1
onboot: 0
ostype: win11
scsi0: local-zfs:vm-102-disk-1,iothread=1,size=128G
scsihw: virtio-scsi-pci
smbios1: uuid=e8759450-5e4c-4a61-be4e-b5bfb4b28545
tablet: 1
tpmstate0: local-zfs:vm-102-disk-2,size=4M,version=v2.0
usb0: host=spice,usb3=1
vga: qxl
vmgenid: b6a298ed-b9be-4da9-9777-5048106e4459
+23
View File
@@ -0,0 +1,23 @@
agent: enabled=1
bios: ovmf
boot: order=scsi0
cicustom: user=local:snippets/claude-code-userdata.yaml
citype: nocloud
cores: 4
cpu: host
efidisk0: local-zfs:vm-105-disk-0,efitype=4m,pre-enrolled-keys=0,size=1M
ide2: local-zfs:vm-105-cloudinit,media=cdrom
ipconfig0: ip=10.10.2.10/24,gw=10.10.2.1
machine: q35
memory: 8192
name: claude-code
nameserver: 10.10.1.2
net0: virtio=BC:24:11:E3:A1:D1,bridge=vmbr2,firewall=1
onboot: 1
ostype: l26
scsi0: local-zfs:vm-105-disk-1,iothread=1,size=20G
scsihw: virtio-scsi-single
searchdomain: homelab
smbios1: uuid=cc68aa1f-affe-42ee-8086-ba94b69573af
vga: qxl
vmgenid: 511710d7-9a40-43ef-901e-0a2de8344f5b
-28
View File
@@ -1,28 +0,0 @@
# VM disks on SSD pool — snapshot daily/weekly
[rpool/data]
use_template = vms
recursive = yes
# Proxmox OS root — lighter schedule, mainly for recovery
[rpool/ROOT]
use_template = system
recursive = yes
#############################
[template_vms]
daily = 7
weekly = 4
monthly = 0
hourly = 0
frequently = 0
autosnap = yes
autoprune = yes
[template_system]
daily = 3
weekly = 2
monthly = 0
hourly = 0
frequently = 0
autosnap = yes
autoprune = yes
-8
View File
@@ -1,8 +0,0 @@
[Unit]
Description=Syncoid ZFS replication to HDD
After=zfs.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/syncoid --recursive --no-privilege-elevation rpool/data hdd/backups/rpool-data
ExecStart=/usr/sbin/syncoid --no-privilege-elevation rpool/ROOT/pve-1 hdd/backups/pve-root
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
FAIL=0
errors=()
# Files that must never be staged (matched against path)
FORBIDDEN_PATHS=(
'vault_pass$'
'\.secret$'
'\.vault$'
'_ed25519$'
'_rsa$'
'_dsa$'
'_ecdsa$'
'user_credentials\.json$'
'\.env$'
)
# Regex patterns that indicate secrets in file content.
# Each is matched with: grep -qiE -e "$pattern"
SECRET_PATTERNS=(
'BEGIN (RSA|EC|DSA|OPENSSH) PRIVATE KEY'
'password[[:space:]]*[:=][[:space:]]*["\x27]?[A-Za-z0-9+/!@#$%^&*]{16,}'
'secret_key[[:space:]]*[:=][[:space:]]*["\x27][A-Za-z0-9+/]{24,}'
'internal_token[[:space:]]*[:=][[:space:]]*["\x27][A-Za-z0-9+/]{24,}'
'token[[:space:]]*[:=][[:space:]]*["\x27][a-f0-9]{40}'
'api[_-]?key[[:space:]]*[:=][[:space:]]*["\x27][A-Za-z0-9]{24,}'
)
# Lines matching these are skipped before pattern matching (false-positive suppression)
SAFE_PATTERNS=(
'\$ANSIBLE_VAULT'
'\{\{.*\}\}'
'^[[:space:]]*#'
'vault_password_file'
'^vault_[a-z_]*:'
'grafana_admin_password: admin'
'_password:.*vault_'
'Example\|example\|placeholder\|CHANGEME'
)
staged=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null)
[[ -z "$staged" ]] && exit 0
# --- Check forbidden file paths ---
for f in $staged; do
for pattern in "${FORBIDDEN_PATHS[@]}"; do
if echo "$f" | grep -qE -e "$pattern"; then
errors+=("FORBIDDEN FILE: $f (matches: $pattern)")
FAIL=1
fi
done
done
# --- Check staged content for secret patterns ---
for f in $staged; do
# Skip binary files
if git diff --cached -- "$f" | grep -qP '^\+.*[\x00-\x08\x0b-\x1f\x7f-\xff]' 2>/dev/null; then
continue
fi
while IFS= read -r line; do
# Strip leading '+' from diff hunk line
content="${line:1}"
# Skip safe/allowlisted lines
skip=0
for safe in "${SAFE_PATTERNS[@]}"; do
if echo "$content" | grep -qE -e "$safe"; then
skip=1
break
fi
done
[[ $skip -eq 1 ]] && continue
# Check against secret patterns
for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$content" | grep -qiE -e "$pattern"; then
# Redact the actual value in the error message
redacted=$(echo "$content" | sed -E 's/([=:][[:space:]]*["\x27]?)[A-Za-z0-9+\/!@#$%^&*]{8,}/\1**REDACTED**/g')
errors+=("SECRET in $f: ${redacted:0:120}")
FAIL=1
break
fi
done
done < <(git diff --cached -- "$f" | grep -E '^\+[^+]')
done
# --- Report ---
if [[ ${FAIL} -eq 1 ]]; then
echo "" >&2
echo "pre-commit: potential secrets detected — commit blocked" >&2
echo "" >&2
for e in "${errors[@]}"; do
echo " ✗ $e" >&2
done
echo "" >&2
echo " To bypass (only if certain this is a false positive):" >&2
echo " git commit --no-verify" >&2
echo "" >&2
exit 1
fi
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Run once after cloning: ./scripts/install-hooks.sh
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
HOOKS_SRC="$REPO_ROOT/scripts/hooks"
HOOKS_DST="$REPO_ROOT/.git/hooks"
for hook in "$HOOKS_SRC"/*; do
name=$(basename "$hook")
cp "$hook" "$HOOKS_DST/$name"
chmod +x "$HOOKS_DST/$name"
echo "Installed: .git/hooks/$name"
done