Compare commits
17 Commits
36c63d1243
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c707a7cceb | |||
| bc838dfcb6 | |||
| f4c1651d17 | |||
| 3bb01ddaaf | |||
| 30b2d42fcc | |||
| 80a3250cf6 | |||
| 1d878a8d45 | |||
| b9cb93b691 | |||
| f2321e1f45 | |||
| 1cf6c81ac5 | |||
| 18e05c2f88 | |||
| 7ba749c620 | |||
| e888560175 | |||
| 7ba68de68d | |||
| d1d38df0e4 | |||
| 62e5027d24 | |||
| 3d596b984b |
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"e2025a38-3d43-46f4-8c97-bbac5bc5681b","pid":40455,"procStart":"489090","acquiredAt":1778850952882}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
keys/
|
keys/
|
||||||
wireguard/
|
wireguard/
|
||||||
|
certs/
|
||||||
*.vault
|
*.vault
|
||||||
*.secret
|
*.secret
|
||||||
ansible/vault_pass
|
ansible/vault_pass
|
||||||
user_credentials.json
|
user_credentials.json
|
||||||
archinstall-configs/creds.json
|
archinstall-configs/creds.json
|
||||||
vms/
|
vms/
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,35 +1,38 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
30353336323330616638366131616265353935386336623962313139666630386233333435376132
|
31323435376435353462306465366666306330303932626563666533616264343239336236653865
|
||||||
6632383465666533323435613938636564636166353265300a636531383030323130356439663538
|
6362303830313335363234663639323132313064383234650a613661353637613934666538653036
|
||||||
39373933303438346138306430393831663236626132353031623837643563353536383432393866
|
65343031666139326139626634653861633161633565333539663339633435306664396564656564
|
||||||
3963613139663531620a613631636261653764663539323363326538373438326666646666643432
|
3361396638313836340a616539323161333465336162363063643832363231653037346234363533
|
||||||
66663430333263653439613937326134653830663631363236663238663933613363343039393734
|
66313661343562643663623132333138663536643062323064353632666365653435623233643634
|
||||||
62313035653337363836633333636231643635656134396639653431646236353737353133343631
|
61363034363562643733323164633130666133623034373639383663613661323065383230396265
|
||||||
30343237373232356532363732366236343565353739353065363562643932386164366163343235
|
65366331636235633534636237383332643436336665663366653164313266353236363362383864
|
||||||
33363837626434306339623733623464646633656431643732303330353232656439313639363362
|
32646131613136393863383031646163356463396662346231366365363436393961333462303434
|
||||||
33376364343965323464663634386530303462613833333136623635366537363265646163653038
|
64313738393132636537653438393030623363373037343930636137373436646630303137393035
|
||||||
38376563353865636437313838313236333662376430303930356432663261383834626563343139
|
33666238363039623266666538623634323165363262636565393134393530343137383965303662
|
||||||
66643730373239623737383138663935313031373062646363663865366233383663336330333134
|
30336536646431633235623636316132613437306338646435383037633534306336393538666237
|
||||||
30333032366131363630303735653265636337616531656133343935306136303830333439656230
|
39656330376339323464313134326165316163393965633964343136633035653934386633646133
|
||||||
36303565643939616138323932393637373935396264666162356538313165656438306232613665
|
32386235373031383037613835373966356334383539383162393136616566376335366239323961
|
||||||
64666334306134323730383665666664303930646630343636653335653735323561393061323031
|
38616334343238373361306561656635653334633561366139386465333462336161333637643234
|
||||||
65643831353464353235626535346337663263396432356232323434653562656365386663636131
|
63393231353166376362323434396265303630663530373538373665326662386264383762623835
|
||||||
39663136313664353331373164616331626135356234623961663430653661316465393439643033
|
37366234313930376366386336303963376232393066373133616332613439343430303664656137
|
||||||
64653562383533383839386237323439376139333962396336386633653566656563636533366336
|
37646630373332636136653634346363323766376635343838336531613934626362393961656266
|
||||||
35313366336266633636383763643463376134316366393235313964313239373836336665353339
|
65363333346664633261666132633166656665316537343464653436393662656565393136613839
|
||||||
66303531336333646333326265646331353363653036313261663462626462653964306465383665
|
30633932333062653836616637366365333937366134613965623139323163316332333636646439
|
||||||
66313331656538336534653166363535336232316363303335663835656566623132383131373436
|
64363238346430346437666363373463376630363539613365313231373237366666353737383264
|
||||||
36366235643935373061366565623761333335666163326464326636626462663661346163323438
|
39633261623231323962383564346265386633646664386461646332646637613363613134333163
|
||||||
64653234333236383039393963336235643163653834303237366363633330613639353332616535
|
65396362313139633436623839333032346436626633336165343665346133353330623833333530
|
||||||
61323265346161313561396435643630383435633466636639316665353366343266306232663439
|
30623362373261633630356563346231616462666336316165376230623166306362623734376538
|
||||||
32653662666631636662323833646562353861333365303430326130633832313162316337643063
|
36373039333538376634376534376431363462383764366462643436323964313361643732393964
|
||||||
34393038646164363837306461303634653464343232663139363237353939323137333238613537
|
37633635346433653636333432653365636666633963363230646636393035613335336231303138
|
||||||
34616139376632373931343437316337383332343162363939633634303831353336333433633935
|
32623735353465653230333264393137396265373866363666303264656135393464343633653063
|
||||||
39343439313865343036376634653863653137653131363866363761653065313738316663383239
|
33396539393566363735663537626634326432643836393161373837643561653432393237386337
|
||||||
38383232626262303463393164326366633138313932366530663136616233313831303337316537
|
32353962363964383363393833666165363334303462653736346538663762353133666165303034
|
||||||
38343661663264303337346161323135366164373039663934343261663237396563383239663262
|
39313833353465316531643238353238316231303638373964363861623766326565316438313839
|
||||||
39383765643765396331333938666335663862303962353562663361323764643031373431306664
|
38393735643635633431643634633436626335353462623766363663323132636263383235616237
|
||||||
61646561353732323765363034326362663239666634316639356232633934663130663431613236
|
65326334346462316630376532656338306438303639353537373766333566376238646265663237
|
||||||
64336335653664646430653061313836386361316564353334336364626332303839363335343363
|
65623366373766376636303033616566313131386564316135323436653563326464643662383534
|
||||||
65653931363331383834373364616538336135633936363533383461373931666666373863386438
|
38393061346339346461323438656239336236646166343738663232373565393763313365363439
|
||||||
3231333561313139393966383964613731616139666536613536
|
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
|
ansible_user: adyrem
|
||||||
|
|
||||||
|
network_interface: ens18
|
||||||
|
network_prefix: 24
|
||||||
|
network_gateway: 10.10.1.1
|
||||||
|
network_dns: 10.10.1.2
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,6 +22,16 @@ all:
|
|||||||
hosts:
|
hosts:
|
||||||
gitea:
|
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:
|
pihole_servers:
|
||||||
hosts:
|
hosts:
|
||||||
pihole:
|
pihole:
|
||||||
@@ -29,3 +39,8 @@ all:
|
|||||||
traefik_servers:
|
traefik_servers:
|
||||||
hosts:
|
hosts:
|
||||||
traefik:
|
traefik:
|
||||||
|
|
||||||
|
proxmox_hosts:
|
||||||
|
hosts:
|
||||||
|
proxmox:
|
||||||
|
ansible_host: 192.168.1.10
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
- name: Configure Claude Code VM
|
||||||
|
hosts: claude_code_vms
|
||||||
|
roles:
|
||||||
|
- common
|
||||||
|
- node_exporter
|
||||||
|
- promtail
|
||||||
|
- claude_code
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Deploy Jellyfin media stack
|
||||||
|
hosts: media_servers
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- jellyfin
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
files/homelab_ed25519
|
||||||
|
files/claude-code_ed25519
|
||||||
@@ -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
|
||||||
@@ -16,6 +16,26 @@
|
|||||||
key: "{{ admin_ssh_key }}"
|
key: "{{ admin_ssh_key }}"
|
||||||
state: present
|
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
|
- name: Install base packages
|
||||||
community.general.pacman:
|
community.general.pacman:
|
||||||
name:
|
name:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
gitea_http_port: 3000
|
gitea_http_port: 3000
|
||||||
gitea_domain: "{{ ansible_host }}"
|
gitea_domain: "{{ ansible_host }}"
|
||||||
|
gitea_root_url: "https://adyrem.duckdns.org/git/"
|
||||||
gitea_db_name: gitea
|
gitea_db_name: gitea
|
||||||
gitea_db_user: gitea
|
gitea_db_user: gitea
|
||||||
gitea_db_password: "{{ vault_gitea_db_password }}"
|
gitea_db_password: "{{ vault_gitea_db_password }}"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ RUN_MODE = prod
|
|||||||
DOMAIN = {{ gitea_domain }}
|
DOMAIN = {{ gitea_domain }}
|
||||||
HTTP_ADDR = 0.0.0.0
|
HTTP_ADDR = 0.0.0.0
|
||||||
HTTP_PORT = {{ gitea_http_port }}
|
HTTP_PORT = {{ gitea_http_port }}
|
||||||
ROOT_URL = http://{{ gitea_domain }}:{{ gitea_http_port }}/
|
ROOT_URL = {{ gitea_root_url }}
|
||||||
SSH_DOMAIN = {{ gitea_domain }}
|
SSH_DOMAIN = {{ gitea_domain }}
|
||||||
SSH_PORT = 22
|
SSH_PORT = 22
|
||||||
START_SSH_SERVER = false
|
START_SSH_SERVER = false
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -8,4 +8,14 @@ pihole_dns_hosts:
|
|||||||
- { ip: "10.10.1.2", host: "pihole.homelab" }
|
- { ip: "10.10.1.2", host: "pihole.homelab" }
|
||||||
- { ip: "10.10.1.3", host: "traefik.homelab" }
|
- { ip: "10.10.1.3", host: "traefik.homelab" }
|
||||||
- { ip: "10.10.1.125", host: "gitea.homelab" }
|
- { ip: "10.10.1.125", host: "gitea.homelab" }
|
||||||
- { ip: "10.10.1.137", host: "grafana.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" }
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ hosts = [
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
]
|
]
|
||||||
|
|
||||||
hosts_str = "[" + ", ".join('"' + h + '"' for h in hosts) + "]"
|
hosts_str = "[\n" + "".join(' "' + h + '",\n' for h in hosts) + " ]"
|
||||||
|
|
||||||
with open("/etc/pihole/pihole.toml", "r") as f:
|
with open("/etc/pihole/pihole.toml", "r") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
|
# Only replace the first hosts= (DNS custom records section, not DHCP leases)
|
||||||
content = re.sub(
|
content = re.sub(
|
||||||
r"(?m)^( *hosts\s*=\s*)\[.*\]",
|
r"( *hosts\s*=\s*)\[.*?\]",
|
||||||
r"\g<1>" + hosts_str,
|
r"\g<1>" + hosts_str,
|
||||||
content,
|
content,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open("/etc/pihole/pihole.toml", "w") as f:
|
with open("/etc/pihole/pihole.toml", "w") as f:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
After=sanoid.timer
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=*-*-* 02:00:00
|
OnCalendar={{ syncoid_schedule }}
|
||||||
RandomizedDelaySec=10min
|
RandomizedDelaySec={{ syncoid_randomized_delay }}
|
||||||
Persistent=true
|
Persistent=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -4,14 +4,56 @@ traefik_http_port: 80
|
|||||||
traefik_https_port: 443
|
traefik_https_port: 443
|
||||||
traefik_dashboard_port: 8080
|
traefik_dashboard_port: 8080
|
||||||
traefik_log_level: "INFO"
|
traefik_log_level: "INFO"
|
||||||
|
traefik_acme_email: "fanfohcd@gmail.com"
|
||||||
|
|
||||||
traefik_services:
|
traefik_services:
|
||||||
- name: gitea
|
- name: gitea
|
||||||
host: "gitea.homelab"
|
host: "gitea.homelab"
|
||||||
backend: "http://10.10.1.125:3000"
|
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
|
- name: grafana
|
||||||
host: "grafana.homelab"
|
host: "grafana.homelab"
|
||||||
backend: "http://10.10.1.137:3000"
|
backend: "http://10.10.1.137:3000"
|
||||||
- name: pihole
|
- name: pihole
|
||||||
host: "pihole.homelab"
|
host: "pihole.homelab"
|
||||||
backend: "http://10.10.1.2:80"
|
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
|
||||||
|
|||||||
@@ -26,6 +26,15 @@
|
|||||||
- /etc/traefik
|
- /etc/traefik
|
||||||
- /etc/traefik/conf.d
|
- /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
|
- name: Check installed Traefik version
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: /usr/local/bin/traefik version
|
cmd: /usr/local/bin/traefik version
|
||||||
@@ -51,6 +60,41 @@
|
|||||||
when: traefik_version not in (traefik_installed_version.stdout | default(''))
|
when: traefik_version not in (traefik_installed_version.stdout | default(''))
|
||||||
notify: Restart traefik
|
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
|
- name: Write Traefik static config
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: traefik.yml.j2
|
src: traefik.yml.j2
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
{% for svc in traefik_services %}
|
{% for svc in traefik_services %}
|
||||||
{{ svc.name }}:
|
{{ svc.name }}-internal:
|
||||||
rule: "Host(`{{ svc.host }}`)"
|
rule: "Host(`{{ svc.host }}`)"
|
||||||
service: {{ svc.name }}
|
service: {{ svc.name }}
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- web
|
- 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 %}
|
{% endfor %}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -14,4 +50,23 @@ http:
|
|||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
- url: "{{ svc.backend }}"
|
- url: "{{ svc.backend }}"
|
||||||
|
{% if svc.tls_skip_verify is defined and svc.tls_skip_verify %}
|
||||||
|
serversTransport: {{ svc.name }}-transport
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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
|
||||||
@@ -5,11 +5,21 @@ api:
|
|||||||
entryPoints:
|
entryPoints:
|
||||||
web:
|
web:
|
||||||
address: ":{{ traefik_http_port }}"
|
address: ":{{ traefik_http_port }}"
|
||||||
|
websecure:
|
||||||
|
address: ":{{ traefik_https_port }}"
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
file:
|
file:
|
||||||
directory: /etc/traefik/conf.d
|
directory: /etc/traefik/conf.d
|
||||||
watch: true
|
watch: true
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
letsencrypt:
|
||||||
|
acme:
|
||||||
|
email: {{ traefik_acme_email }}
|
||||||
|
storage: /etc/traefik/acme.json
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: {{ traefik_log_level }}
|
level: {{ traefik_log_level }}
|
||||||
|
|||||||
@@ -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
+82
-1
@@ -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)
|
- External/cloud backups (architecture must not preclude adding later)
|
||||||
- Additional VM distros beyond those listed — template mechanism should make adding easy
|
- Additional VM distros beyond those listed — template mechanism should make adding easy
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
Executable
+103
@@ -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
|
||||||
Executable
+14
@@ -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
|
||||||
Reference in New Issue
Block a user