From 7ba749c6203823d9788bc0461fab29ceeafd9e2e Mon Sep 17 00:00:00 2001 From: Adyrem Date: Sat, 16 May 2026 04:06:56 +0200 Subject: [PATCH] Add Proxmox host role, WireGuard VPN, and public Gitea via Traefik HTTPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox host role: - Admin user setup, SSH hardening (port 2222, no root login) - claude-code SFTP chroot user - Unattended upgrades, DuckDNS dynamic DNS updater (vault-encrypted token) - WireGuard VPN server (10.10.10.0/24) with Fedora client registered - Proxmox firewall management via pvesh API (idempotent Python script) Public Gitea exposure: - Traefik: add HTTPS entrypoint, Let's Encrypt ACME (HTTP-01), StripPrefix middleware for /git subpath, HTTP→HTTPS redirect - Gitea ROOT_URL updated to https://adyrem.duckdns.org/git/ - Pi-hole split DNS: adyrem.duckdns.org → 10.10.1.3 for internal hairpin bypass SSH config fixes: - StrictHostKeyChecking no → accept-new across all hosts - Proxmox jump host: port 2222, user adyrem (root login disabled) Co-Authored-By: Claude Sonnet 4.6 --- .../group_vars/claude_code_vms/vars.yml | 2 +- .../group_vars/proxmox_hosts/vars.yml | 17 ++ .../group_vars/proxmox_hosts/vault.yml | 8 + ansible/inventory/hosts.yml | 5 + ansible/playbooks/host-README.md | 84 +++++++ ansible/playbooks/host.yml | 13 ++ ansible/playbooks/wireguard-README.md | 111 ++++++++++ .../roles/claude_code/templates/ssh_config.j2 | 16 +- ansible/roles/gitea/defaults/main.yml | 1 + ansible/roles/gitea/templates/app.ini.j2 | 2 +- ansible/roles/pihole/defaults/main.yml | 1 + ansible/roles/proxmox_host/defaults/main.yml | 1 + .../roles/proxmox_host/files/duckdns.service | 8 + .../roles/proxmox_host/files/duckdns.timer | 9 + .../roles/proxmox_host/files/pve_firewall.py | 49 +++++ ansible/roles/proxmox_host/handlers/main.yml | 14 ++ ansible/roles/proxmox_host/tasks/main.yml | 207 ++++++++++++++++++ .../templates/50unattended-upgrades.j2 | 13 ++ .../templates/duckdns-update.sh.j2 | 4 + .../templates/sshd_homelab.conf.j2 | 11 + .../roles/proxmox_host/templates/wg0.conf.j2 | 15 ++ ansible/roles/traefik/defaults/main.yml | 4 + ansible/roles/traefik/tasks/main.yml | 9 + ansible/roles/traefik/templates/routes.yml.j2 | 36 ++- .../roles/traefik/templates/traefik.yml.j2 | 10 + proxmox/firewall/host.fw | 5 +- 26 files changed, 644 insertions(+), 11 deletions(-) create mode 100644 ansible/inventory/group_vars/proxmox_hosts/vars.yml create mode 100644 ansible/inventory/group_vars/proxmox_hosts/vault.yml create mode 100644 ansible/playbooks/host-README.md create mode 100644 ansible/playbooks/host.yml create mode 100644 ansible/playbooks/wireguard-README.md create mode 100644 ansible/roles/proxmox_host/defaults/main.yml create mode 100644 ansible/roles/proxmox_host/files/duckdns.service create mode 100644 ansible/roles/proxmox_host/files/duckdns.timer create mode 100644 ansible/roles/proxmox_host/files/pve_firewall.py create mode 100644 ansible/roles/proxmox_host/handlers/main.yml create mode 100644 ansible/roles/proxmox_host/tasks/main.yml create mode 100644 ansible/roles/proxmox_host/templates/50unattended-upgrades.j2 create mode 100644 ansible/roles/proxmox_host/templates/duckdns-update.sh.j2 create mode 100644 ansible/roles/proxmox_host/templates/sshd_homelab.conf.j2 create mode 100644 ansible/roles/proxmox_host/templates/wg0.conf.j2 diff --git a/ansible/inventory/group_vars/claude_code_vms/vars.yml b/ansible/inventory/group_vars/claude_code_vms/vars.yml index 199b7c9..3944200 100644 --- a/ansible/inventory/group_vars/claude_code_vms/vars.yml +++ b/ansible/inventory/group_vars/claude_code_vms/vars.yml @@ -1,2 +1,2 @@ ansible_user: adyrem -ansible_ssh_common_args: "-o ProxyJump=root@192.168.1.10 -o StrictHostKeyChecking=no" +ansible_ssh_common_args: "-o ProxyJump=root@192.168.1.10 -o StrictHostKeyChecking=accept-new" diff --git a/ansible/inventory/group_vars/proxmox_hosts/vars.yml b/ansible/inventory/group_vars/proxmox_hosts/vars.yml new file mode 100644 index 0000000..1fe0565 --- /dev/null +++ b/ansible/inventory/group_vars/proxmox_hosts/vars.yml @@ -0,0 +1,17 @@ +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 diff --git a/ansible/inventory/group_vars/proxmox_hosts/vault.yml b/ansible/inventory/group_vars/proxmox_hosts/vault.yml new file mode 100644 index 0000000..fec32b6 --- /dev/null +++ b/ansible/inventory/group_vars/proxmox_hosts/vault.yml @@ -0,0 +1,8 @@ +vault_duckdns_token: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 34386432626362373134333765386630616466383661623463363465353135633763623364366166 + 3233343837653638613262663537383630376635323031350a653464653166396165643136643163 + 66376663666332303337306165313337303261373033363834373031303335383839383139663461 + 3036356435613435660a323432303964326362646462653139656664653439346331323261636436 + 62343135373431326231333736613836366238663064623163336636393663633862383439363333 + 6530326532373062636237613765353039373164333063613331 diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml index 3d3c03c..de41926 100644 --- a/ansible/inventory/hosts.yml +++ b/ansible/inventory/hosts.yml @@ -34,3 +34,8 @@ all: traefik_servers: hosts: traefik: + + proxmox_hosts: + hosts: + proxmox: + ansible_host: 192.168.1.10 diff --git a/ansible/playbooks/host-README.md b/ansible/playbooks/host-README.md new file mode 100644 index 0000000..517a15d --- /dev/null +++ b/ansible/playbooks/host-README.md @@ -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 diff --git a/ansible/playbooks/host.yml b/ansible/playbooks/host.yml new file mode 100644 index 0000000..598fcf1 --- /dev/null +++ b/ansible/playbooks/host.yml @@ -0,0 +1,13 @@ +--- +# 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 diff --git a/ansible/playbooks/wireguard-README.md b/ansible/playbooks/wireguard-README.md new file mode 100644 index 0000000..d3c4c2f --- /dev/null +++ b/ansible/playbooks/wireguard-README.md @@ -0,0 +1,111 @@ +# Adding a new WireGuard client + +## Current peer assignments + +| Name | IP | +|--------|--------------| +| server | 10.10.10.1 | +| fedora | 10.10.10.2 | + +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: "" + 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 = +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 diff --git a/ansible/roles/claude_code/templates/ssh_config.j2 b/ansible/roles/claude_code/templates/ssh_config.j2 index b997930..3c9047f 100644 --- a/ansible/roles/claude_code/templates/ssh_config.j2 +++ b/ansible/roles/claude_code/templates/ssh_config.j2 @@ -1,16 +1,18 @@ # Proxmox host — jump host only, no shell Host proxmox HostName 192.168.1.10 - User root + Port 2222 + User adyrem IdentityFile ~/.ssh/homelab_ed25519 - StrictHostKeyChecking no + 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 no + StrictHostKeyChecking accept-new # Infra VMs — via ProxyJump through Proxmox Host gitea-vm @@ -18,25 +20,25 @@ Host gitea-vm User adyrem ProxyJump proxmox IdentityFile ~/.ssh/homelab_ed25519 - StrictHostKeyChecking no + StrictHostKeyChecking accept-new Host monitoring-vm HostName 10.10.1.137 User adyrem ProxyJump proxmox IdentityFile ~/.ssh/homelab_ed25519 - StrictHostKeyChecking no + StrictHostKeyChecking accept-new Host pihole-ct HostName 10.10.1.2 User root ProxyJump proxmox IdentityFile ~/.ssh/homelab_ed25519 - StrictHostKeyChecking no + StrictHostKeyChecking accept-new Host traefik-ct HostName 10.10.1.3 User root ProxyJump proxmox IdentityFile ~/.ssh/homelab_ed25519 - StrictHostKeyChecking no + StrictHostKeyChecking accept-new diff --git a/ansible/roles/gitea/defaults/main.yml b/ansible/roles/gitea/defaults/main.yml index 989564b..5bd8e0d 100644 --- a/ansible/roles/gitea/defaults/main.yml +++ b/ansible/roles/gitea/defaults/main.yml @@ -1,6 +1,7 @@ --- gitea_http_port: 3000 gitea_domain: "{{ ansible_host }}" +gitea_root_url: "https://adyrem.duckdns.org/git/" gitea_db_name: gitea gitea_db_user: gitea gitea_db_password: "{{ vault_gitea_db_password }}" diff --git a/ansible/roles/gitea/templates/app.ini.j2 b/ansible/roles/gitea/templates/app.ini.j2 index 83897a5..846373e 100644 --- a/ansible/roles/gitea/templates/app.ini.j2 +++ b/ansible/roles/gitea/templates/app.ini.j2 @@ -6,7 +6,7 @@ RUN_MODE = prod DOMAIN = {{ gitea_domain }} HTTP_ADDR = 0.0.0.0 HTTP_PORT = {{ gitea_http_port }} -ROOT_URL = http://{{ gitea_domain }}:{{ gitea_http_port }}/ +ROOT_URL = {{ gitea_root_url }} SSH_DOMAIN = {{ gitea_domain }} SSH_PORT = 22 START_SSH_SERVER = false diff --git a/ansible/roles/pihole/defaults/main.yml b/ansible/roles/pihole/defaults/main.yml index 3531002..979fb4d 100644 --- a/ansible/roles/pihole/defaults/main.yml +++ b/ansible/roles/pihole/defaults/main.yml @@ -9,3 +9,4 @@ pihole_dns_hosts: - { ip: "10.10.1.3", host: "traefik.homelab" } - { ip: "10.10.1.125", host: "gitea.homelab" } - { ip: "10.10.1.137", host: "grafana.homelab" } + - { ip: "10.10.1.3", host: "adyrem.duckdns.org" } diff --git a/ansible/roles/proxmox_host/defaults/main.yml b/ansible/roles/proxmox_host/defaults/main.yml new file mode 100644 index 0000000..a606f6e --- /dev/null +++ b/ansible/roles/proxmox_host/defaults/main.yml @@ -0,0 +1 @@ +ssh_port: 2222 diff --git a/ansible/roles/proxmox_host/files/duckdns.service b/ansible/roles/proxmox_host/files/duckdns.service new file mode 100644 index 0000000..57ce38b --- /dev/null +++ b/ansible/roles/proxmox_host/files/duckdns.service @@ -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 diff --git a/ansible/roles/proxmox_host/files/duckdns.timer b/ansible/roles/proxmox_host/files/duckdns.timer new file mode 100644 index 0000000..8d5ce82 --- /dev/null +++ b/ansible/roles/proxmox_host/files/duckdns.timer @@ -0,0 +1,9 @@ +[Unit] +Description=DuckDNS IP update — every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/ansible/roles/proxmox_host/files/pve_firewall.py b/ansible/roles/proxmox_host/files/pve_firewall.py new file mode 100644 index 0000000..c51227a --- /dev/null +++ b/ansible/roles/proxmox_host/files/pve_firewall.py @@ -0,0 +1,49 @@ +#!/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': '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') diff --git a/ansible/roles/proxmox_host/handlers/main.yml b/ansible/roles/proxmox_host/handlers/main.yml new file mode 100644 index 0000000..e822c99 --- /dev/null +++ b/ansible/roles/proxmox_host/handlers/main.yml @@ -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 diff --git a/ansible/roles/proxmox_host/tasks/main.yml b/ansible/roles/proxmox_host/tasks/main.yml new file mode 100644 index 0000000..7e97ceb --- /dev/null +++ b/ansible/roles/proxmox_host/tasks/main.yml @@ -0,0 +1,207 @@ +# --- 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 diff --git a/ansible/roles/proxmox_host/templates/50unattended-upgrades.j2 b/ansible/roles/proxmox_host/templates/50unattended-upgrades.j2 new file mode 100644 index 0000000..1a5e1ec --- /dev/null +++ b/ansible/roles/proxmox_host/templates/50unattended-upgrades.j2 @@ -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"; diff --git a/ansible/roles/proxmox_host/templates/duckdns-update.sh.j2 b/ansible/roles/proxmox_host/templates/duckdns-update.sh.j2 new file mode 100644 index 0000000..23503c1 --- /dev/null +++ b/ansible/roles/proxmox_host/templates/duckdns-update.sh.j2 @@ -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 diff --git a/ansible/roles/proxmox_host/templates/sshd_homelab.conf.j2 b/ansible/roles/proxmox_host/templates/sshd_homelab.conf.j2 new file mode 100644 index 0000000..4893fee --- /dev/null +++ b/ansible/roles/proxmox_host/templates/sshd_homelab.conf.j2 @@ -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 diff --git a/ansible/roles/proxmox_host/templates/wg0.conf.j2 b/ansible/roles/proxmox_host/templates/wg0.conf.j2 new file mode 100644 index 0000000..29f542b --- /dev/null +++ b/ansible/roles/proxmox_host/templates/wg0.conf.j2 @@ -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 %} diff --git a/ansible/roles/traefik/defaults/main.yml b/ansible/roles/traefik/defaults/main.yml index c8cff7e..b9bffe8 100644 --- a/ansible/roles/traefik/defaults/main.yml +++ b/ansible/roles/traefik/defaults/main.yml @@ -4,11 +4,15 @@ traefik_http_port: 80 traefik_https_port: 443 traefik_dashboard_port: 8080 traefik_log_level: "INFO" +traefik_acme_email: "fanfohcd@gmail.com" traefik_services: - name: gitea host: "gitea.homelab" backend: "http://10.10.1.125:3000" + public_host: "adyrem.duckdns.org" + public_path: "/git" + strip_prefix: "/git" - name: grafana host: "grafana.homelab" backend: "http://10.10.1.137:3000" diff --git a/ansible/roles/traefik/tasks/main.yml b/ansible/roles/traefik/tasks/main.yml index eada09e..02e3e92 100644 --- a/ansible/roles/traefik/tasks/main.yml +++ b/ansible/roles/traefik/tasks/main.yml @@ -26,6 +26,15 @@ - /etc/traefik - /etc/traefik/conf.d +- name: Create acme.json for Let's Encrypt certificate storage + ansible.builtin.copy: + dest: /etc/traefik/acme.json + content: "" + owner: traefik + group: traefik + mode: "0600" + force: false + - name: Check installed Traefik version ansible.builtin.command: cmd: /usr/local/bin/traefik version diff --git a/ansible/roles/traefik/templates/routes.yml.j2 b/ansible/roles/traefik/templates/routes.yml.j2 index 3c6c5b0..54bd09e 100644 --- a/ansible/roles/traefik/templates/routes.yml.j2 +++ b/ansible/roles/traefik/templates/routes.yml.j2 @@ -1,11 +1,45 @@ http: routers: {% for svc in traefik_services %} - {{ svc.name }}: + {{ svc.name }}-internal: rule: "Host(`{{ svc.host }}`)" service: {{ svc.name }} entryPoints: - web +{% if svc.public_host is defined %} + {{ svc.name }}-public-redirect: + rule: "Host(`{{ svc.public_host }}`){% if svc.public_path is defined %} && PathPrefix(`{{ svc.public_path }}`){% endif %}" + service: {{ svc.name }} + entryPoints: + - web + middlewares: + - redirect-https + {{ svc.name }}-public: + rule: "Host(`{{ svc.public_host }}`){% if svc.public_path is defined %} && PathPrefix(`{{ svc.public_path }}`){% endif %}" + service: {{ svc.name }} + entryPoints: + - websecure + tls: + certResolver: letsencrypt +{% if svc.strip_prefix is defined %} + middlewares: + - {{ svc.name }}-strip-prefix +{% endif %} +{% endif %} +{% endfor %} + + middlewares: + redirect-https: + redirectScheme: + scheme: https + permanent: true +{% for svc in traefik_services %} +{% if svc.strip_prefix is defined %} + {{ svc.name }}-strip-prefix: + stripPrefix: + prefixes: + - "{{ svc.strip_prefix }}" +{% endif %} {% endfor %} services: diff --git a/ansible/roles/traefik/templates/traefik.yml.j2 b/ansible/roles/traefik/templates/traefik.yml.j2 index a539223..1b10661 100644 --- a/ansible/roles/traefik/templates/traefik.yml.j2 +++ b/ansible/roles/traefik/templates/traefik.yml.j2 @@ -5,11 +5,21 @@ api: entryPoints: web: address: ":{{ traefik_http_port }}" + websecure: + address: ":{{ traefik_https_port }}" providers: file: directory: /etc/traefik/conf.d watch: true +certificatesResolvers: + letsencrypt: + acme: + email: {{ traefik_acme_email }} + storage: /etc/traefik/acme.json + httpChallenge: + entryPoint: web + log: level: {{ traefik_log_level }} diff --git a/proxmox/firewall/host.fw b/proxmox/firewall/host.fw index 0789c7f..ce9d4a6 100644 --- a/proxmox/firewall/host.fw +++ b/proxmox/firewall/host.fw @@ -4,9 +4,12 @@ log_level_in: nolog log_level_out: nolog [RULES] -IN ACCEPT -source 192.168.1.0/24 -p tcp -dport 22 +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