Compare commits
2 Commits
7ba68de68d
...
7ba749c620
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ba749c620 | |||
| e888560175 |
@@ -1,2 +1,2 @@
|
|||||||
ansible_user: adyrem
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
vault_duckdns_token: !vault |
|
||||||
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
|
34386432626362373134333765386630616466383661623463363465353135633763623364366166
|
||||||
|
3233343837653638613262663537383630376635323031350a653464653166396165643136643163
|
||||||
|
66376663666332303337306165313337303261373033363834373031303335383839383139663461
|
||||||
|
3036356435613435660a323432303964326362646462653139656664653439346331323261636436
|
||||||
|
62343135373431326231333736613836366238663064623163336636393663633862383439363333
|
||||||
|
6530326532373062636237613765353039373164333063613331
|
||||||
@@ -34,3 +34,8 @@ all:
|
|||||||
traefik_servers:
|
traefik_servers:
|
||||||
hosts:
|
hosts:
|
||||||
traefik:
|
traefik:
|
||||||
|
|
||||||
|
proxmox_hosts:
|
||||||
|
hosts:
|
||||||
|
proxmox:
|
||||||
|
ansible_host: 192.168.1.10
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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: "<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
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
# Proxmox host — jump host only, no shell
|
# Proxmox host — jump host only, no shell
|
||||||
Host proxmox
|
Host proxmox
|
||||||
HostName 192.168.1.10
|
HostName 192.168.1.10
|
||||||
User root
|
Port 2222
|
||||||
|
User adyrem
|
||||||
IdentityFile ~/.ssh/homelab_ed25519
|
IdentityFile ~/.ssh/homelab_ed25519
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
# Proxmox SFTP — write files to host workspace
|
# Proxmox SFTP — write files to host workspace
|
||||||
Host proxmox-sftp
|
Host proxmox-sftp
|
||||||
HostName 192.168.1.10
|
HostName 192.168.1.10
|
||||||
|
Port 2222
|
||||||
User claude-code
|
User claude-code
|
||||||
IdentityFile ~/.ssh/claude-code_ed25519
|
IdentityFile ~/.ssh/claude-code_ed25519
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
# Infra VMs — via ProxyJump through Proxmox
|
# Infra VMs — via ProxyJump through Proxmox
|
||||||
Host gitea-vm
|
Host gitea-vm
|
||||||
@@ -18,25 +20,25 @@ Host gitea-vm
|
|||||||
User adyrem
|
User adyrem
|
||||||
ProxyJump proxmox
|
ProxyJump proxmox
|
||||||
IdentityFile ~/.ssh/homelab_ed25519
|
IdentityFile ~/.ssh/homelab_ed25519
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
Host monitoring-vm
|
Host monitoring-vm
|
||||||
HostName 10.10.1.137
|
HostName 10.10.1.137
|
||||||
User adyrem
|
User adyrem
|
||||||
ProxyJump proxmox
|
ProxyJump proxmox
|
||||||
IdentityFile ~/.ssh/homelab_ed25519
|
IdentityFile ~/.ssh/homelab_ed25519
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
Host pihole-ct
|
Host pihole-ct
|
||||||
HostName 10.10.1.2
|
HostName 10.10.1.2
|
||||||
User root
|
User root
|
||||||
ProxyJump proxmox
|
ProxyJump proxmox
|
||||||
IdentityFile ~/.ssh/homelab_ed25519
|
IdentityFile ~/.ssh/homelab_ed25519
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking accept-new
|
||||||
|
|
||||||
Host traefik-ct
|
Host traefik-ct
|
||||||
HostName 10.10.1.3
|
HostName 10.10.1.3
|
||||||
User root
|
User root
|
||||||
ProxyJump proxmox
|
ProxyJump proxmox
|
||||||
IdentityFile ~/.ssh/homelab_ed25519
|
IdentityFile ~/.ssh/homelab_ed25519
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking accept-new
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ pihole_dns_hosts:
|
|||||||
- { 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.137", host: "grafana.homelab" }
|
||||||
|
- { ip: "10.10.1.3", host: "adyrem.duckdns.org" }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -4,11 +4,15 @@ 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: grafana
|
- name: grafana
|
||||||
host: "grafana.homelab"
|
host: "grafana.homelab"
|
||||||
backend: "http://10.10.1.137:3000"
|
backend: "http://10.10.1.137:3000"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,11 +1,45 @@
|
|||||||
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
|
||||||
|
{% 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:
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ log_level_in: nolog
|
|||||||
log_level_out: nolog
|
log_level_out: nolog
|
||||||
|
|
||||||
[RULES]
|
[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 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 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 udp -dport 53
|
||||||
IN ACCEPT -source 10.10.0.0/16 -p tcp -dport 53
|
IN ACCEPT -source 10.10.0.0/16 -p tcp -dport 53
|
||||||
|
IN ACCEPT -p udp -dport 51820
|
||||||
IN ACCEPT -p icmp
|
IN ACCEPT -p icmp
|
||||||
|
|||||||
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