Add Proxmox host role, WireGuard VPN, and public Gitea via Traefik HTTPS
Ansible Lint / lint (push) Successful in 4s

Proxmox host role:
- Admin user setup, SSH hardening (port 2222, no root login)
- claude-code SFTP chroot user
- Unattended upgrades, DuckDNS dynamic DNS updater (vault-encrypted token)
- WireGuard VPN server (10.10.10.0/24) with Fedora client registered
- Proxmox firewall management via pvesh API (idempotent Python script)

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Adyrem
2026-05-16 04:06:56 +02:00
parent e888560175
commit 7ba749c620
26 changed files with 644 additions and 11 deletions
@@ -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,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')
@@ -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
+207
View File
@@ -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
@@ -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 %}