Add Gitea Actions runner, ansible-lint CI, vault setup, and fix group_vars layout

- Move group_vars into inventory/ so playbooks can find them (was only
  visible to ad-hoc commands via CWD)
- Add Ansible Vault for sensitive variables (gitea/grafana passwords,
  tokens, db credentials) with vault_password_file outside the repo
- Enable Gitea Actions and deploy act_runner with Docker backend on
  the gitea VM
- Add .gitea/workflows/lint.yml to run ansible-lint on every push
- Set up Gitea → GitHub push mirror (sync_on_commit)
- Fix ansible.cfg stdout_callback for ansible-core 2.20 compatibility
- Add python-psycopg2 to gitea role (required for postgresql modules)
- Add docker to gitea_runner role (required by act_runner at startup)
- Exclude password hashes and VM images from git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Adyrem
2026-05-13 17:34:03 +02:00
parent 72d07f49e3
commit 39be9fafe7
25 changed files with 1584 additions and 6 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(command ssh proxmox *)",
"Bash(command ssh gitea-vm *)",
"Bash(command ssh monitoring-vm *)",
"Bash(scp -i /home/adyrem/dev/homelab/keys/homelab_ed25519 * root@192.168.1.10:*)",
"Bash(scp -i /home/adyrem/dev/homelab/keys/homelab_ed25519 root@192.168.1.10:* *)"
]
}
}
+24
View File
@@ -0,0 +1,24 @@
name: Ansible Lint
on:
push:
paths:
- 'ansible/**'
- '.gitea/workflows/lint.yml'
pull_request:
paths:
- 'ansible/**'
jobs:
lint:
runs-on: native
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install ansible-lint
run: pip install ansible-lint --break-system-packages --quiet
- name: Run ansible-lint
working-directory: ansible
run: ansible-lint --profile=min playbooks/site.yml
+3
View File
@@ -3,3 +3,6 @@ wireguard/
*.vault
*.secret
ansible/vault_pass
user_credentials.json
archinstall-configs/creds.json
vms/
+3 -1
View File
@@ -4,8 +4,10 @@ remote_user = adyrem
private_key_file = ../keys/homelab_ed25519
roles_path = roles
host_key_checking = False
stdout_callback = yaml
stdout_callback = default
result_format = yaml
diff_mode = True
vault_password_file = ~/.config/homelab/vault_pass
[privilege_escalation]
become = True
@@ -0,0 +1,30 @@
$ANSIBLE_VAULT;1.1;AES256
31373161366133353539336439393963366530323131303662326630346565326664333330613031
3064633138633062656230626261623132663839343162310a633964633136323634616230313334
34363739636133363830393462376638326632383634313664333066366262323131636337613335
6535353866363935310a363536633861326562626365653439386331643262646338633039316135
64366432333635333162613862636534643730656266353839396165353337333934366633326134
35313637386437306562616536383966336663663064656234306661626533323266336262363834
37326130343966323764303562333038386231623737313762633437393266396365653132646133
38633466363530666532336664636638306461643361613239373735343863646132313835393333
62643763306265636362353934623030663966363530636535326632353637663164323137656239
39336438626334353465613739323939646465393233363366653333363237393930353031393031
33646633363337623664396162393630393365323630616532383233636233613238356236393337
38646364616534303136333338396331333362303730633739353163646263396638623231323332
64636564396633396436393535366366333238656234336261633433616538366434376565646236
39373237663638666236306634643838613765653365396230363465386661623234626534393863
61366336363839373939303138333865663031663338393938663265383564326335626263303563
38646133646533363637653130616361646232613565346561316234383965353038373532633631
64613432303761303161333162316465333432356266316533616636353561353534623032663534
31316234346463316236623265356163383562643161626237393333316161663662336264333465
32363236313862376436663537383531343562353462346132333062666261346462613839636462
38323333356335653637323435643966386332333639623364373764306564646266343339653765
64313936346663646131643135613161616261383233616237323663363063373632373936343831
39663034623165623466653730323565653030616239356463303236653033353438323232666635
33363237613263616264663861623365636230363934383861636266343064326136633430386634
30373132623861386333343637633239353431383761656431643234356437656435653136343138
35343635303931363464383665326134333634663033383937316135616263353763373036643932
34376135623631313061306432356136623137373534643031326465666262393238333335666534
33646532303633356163616465373366623866623930653939373336386331323435343635616563
66356432636364373436646666613764313535363836666164333663323932623236313739306462
34613439393834656535623437643431373761633865333234633866646531316131
+1
View File
@@ -5,3 +5,4 @@
- node_exporter
- promtail
- gitea
- gitea_runner
+5 -5
View File
@@ -3,10 +3,10 @@ gitea_http_port: 3000
gitea_domain: "{{ ansible_host }}"
gitea_db_name: gitea
gitea_db_user: gitea
gitea_db_password: gitea
gitea_secret_key: "REDACTED_SECRET_KEY"
gitea_internal_token: "REDACTED_INTERNAL_TOKEN"
gitea_db_password: "{{ vault_gitea_db_password }}"
gitea_secret_key: "{{ vault_gitea_secret_key }}"
gitea_internal_token: "{{ vault_gitea_internal_token }}"
gitea_admin_user: adyrem
gitea_admin_password: "" # set in vault or at runtime
gitea_admin_email: "" # set in group_vars or vault
gitea_admin_password: "{{ vault_gitea_admin_password }}"
gitea_admin_email: "{{ vault_gitea_admin_email }}"
+1
View File
@@ -4,6 +4,7 @@
name:
- gitea
- postgresql
- python-psycopg2
state: present
- name: Initialize PostgreSQL cluster
+3
View File
@@ -45,6 +45,9 @@ PROVIDER = db
[cache]
ADAPTER = memory
[actions]
ENABLED = true
[indexer]
ISSUE_INDEXER_TYPE = bleve
REPO_INDEXER_ENABLED = true
@@ -0,0 +1,6 @@
---
gitea_runner_version: "0.2.11"
gitea_runner_url: "http://10.10.1.125:3000"
gitea_runner_name: "{{ inventory_hostname }}"
gitea_runner_labels: "native:host"
gitea_runner_registration_token: "{{ vault_gitea_runner_token }}"
@@ -0,0 +1,5 @@
---
- name: Restart act_runner
ansible.builtin.systemd:
name: act_runner
state: restarted
+106
View File
@@ -0,0 +1,106 @@
---
- name: Install act_runner dependencies
community.general.pacman:
name:
- git
- docker
state: present
- name: Enable and start Docker
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Download act_runner binary
ansible.builtin.get_url:
url: "https://dl.gitea.com/act_runner/{{ gitea_runner_version }}/act_runner-{{ gitea_runner_version }}-linux-amd64"
dest: /usr/local/bin/act_runner
mode: "0755"
owner: root
group: root
- name: Create act_runner user
ansible.builtin.user:
name: act_runner
system: true
shell: /sbin/nologin
home: /var/lib/act_runner
create_home: true
groups: docker
append: true
- name: Create act_runner config directory
ansible.builtin.file:
path: /etc/act_runner
state: directory
owner: act_runner
group: act_runner
mode: "0750"
- name: Generate default act_runner config
ansible.builtin.command:
cmd: act_runner generate-config
stdin: ""
register: runner_config_out
changed_when: false
- name: Write act_runner config
ansible.builtin.copy:
content: "{{ runner_config_out.stdout }}"
dest: /etc/act_runner/config.yml
owner: act_runner
group: act_runner
mode: "0640"
force: false
- name: Set runner labels in config
ansible.builtin.replace:
path: /etc/act_runner/config.yml
regexp: '^ labels:$'
replace: " labels:\n - \"native:host\""
notify: Restart act_runner
- name: Register runner with Gitea
ansible.builtin.command:
cmd: >
act_runner register
--no-interactive
--instance "{{ gitea_runner_url }}"
--token "{{ gitea_runner_registration_token }}"
--name "{{ gitea_runner_name }}"
--labels "{{ gitea_runner_labels }}"
--config /etc/act_runner/config.yml
chdir: /var/lib/act_runner
creates: /var/lib/act_runner/.runner
become_user: act_runner
- name: Install act_runner systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/act_runner.service
content: |
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
User=act_runner
WorkingDirectory=/var/lib/act_runner
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
owner: root
group: root
mode: "0644"
notify: Restart act_runner
- name: Enable and start act_runner
ansible.builtin.systemd:
name: act_runner
enabled: true
state: started
daemon_reload: true
+122
View File
@@ -0,0 +1,122 @@
{
"app_config": null,
"archinstall-language": "English",
"auth_config": {},
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [
"mkdir -p /home/adyrem/.ssh && chmod 700 /home/adyrem/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdW4rhQFv561bYs8w0/VgR6AFgOLvVsAh6pcjZ/CO2R homelab' > /home/adyrem/.ssh/authorized_keys && chmod 600 /home/adyrem/.ssh/authorized_keys && chown -R adyrem:adyrem /home/adyrem/.ssh",
"mkdir -p /vms/infra /vms/dev"
],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "__SSD_DEVICE__",
"wipe": true,
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": ["boot", "esp"],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "part-esp",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "name": "@", "mountpoint": "/" },
{ "name": "@home", "mountpoint": "/home" },
{ "name": "@var", "mountpoint": "/var" },
{ "name": "@snapshots", "mountpoint": "/.snapshots" },
{ "name": "@vms-infra", "mountpoint": "/vms/infra" },
{ "name": "@vms-dev", "mountpoint": "/vms/dev" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": ["compress=zstd", "noatime"],
"mountpoint": null,
"obj_id": "part-root",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": "__ROOT_SIZE_BYTES__"
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 1074790400
},
"status": "create",
"type": "primary"
}
]
}
]
},
"hostname": "homelab",
"kernels": ["linux"],
"locale_config": {
"kb_layout": "de_CH-latin1",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": ["https://geo.mirror.pkgbuild.com/$repo/os/$arch"]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"btrbk",
"nftables"
],
"parallel_downloads": 5,
"script": "guided",
"services": ["sshd", "NetworkManager", "nftables"],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.1",
"users": [
{
"enc_password": "$y$j9T$pqMooSvLvxBg2YP7eCQlq/$Ae8.dvSYB817bQkj5w9S4wu9OtWIq.p/pbWY6A/ythD",
"groups": ["wheel"],
"sudo": true,
"username": "adyrem"
}
]
}
+119
View File
@@ -0,0 +1,119 @@
{
"app_config": null,
"archinstall-language": "English",
"auth_config": {},
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [
"mkdir -p /home/adyrem/.ssh && chmod 700 /home/adyrem/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdW4rhQFv561bYs8w0/VgR6AFgOLvVsAh6pcjZ/CO2R homelab' > /home/adyrem/.ssh/authorized_keys && chmod 600 /home/adyrem/.ssh/authorized_keys && chown -R adyrem:adyrem /home/adyrem/.ssh"
],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "/dev/vda",
"wipe": true,
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": ["boot", "esp"],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "part-esp",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "name": "@", "mountpoint": "/" },
{ "name": "@home", "mountpoint": "/home" },
{ "name": "@var", "mountpoint": "/var" },
{ "name": "@snapshots", "mountpoint": "/.snapshots" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": ["compress=zstd"],
"mountpoint": null,
"obj_id": "part-root",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 20398997504
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 1074790400
},
"status": "create",
"type": "primary"
}
]
}
]
},
"hostname": "arch-base",
"kernels": ["linux"],
"locale_config": {
"kb_layout": "de_CH-latin1",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": ["https://geo.mirror.pkgbuild.com/$repo/os/$arch"]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"btrbk",
"qemu-guest-agent"
],
"parallel_downloads": 5,
"script": "guided",
"services": ["sshd", "NetworkManager", "qemu-guest-agent"],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.1",
"users": [
{
"enc_password": "$y$j9T$pqMooSvLvxBg2YP7eCQlq/$Ae8.dvSYB817bQkj5w9S4wu9OtWIq.p/pbWY6A/ythD",
"groups": ["wheel"],
"sudo": true,
"username": "adyrem"
}
]
}
+108
View File
@@ -0,0 +1,108 @@
{
"archinstall-language": "English",
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [
"mkdir -p /home/adyrem/.ssh && chmod 700 /home/adyrem/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdW4rhQFv561bYs8w0/VgR6AFgOLvVsAh6pcjZ/CO2R homelab' > /home/adyrem/.ssh/authorized_keys && chmod 600 /home/adyrem/.ssh/authorized_keys && chown -R adyrem:adyrem /home/adyrem/.ssh"
],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "/dev/vda",
"wipe": true,
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": ["boot", "esp"],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "part-esp",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "name": "@", "mountpoint": "/" },
{ "name": "@home", "mountpoint": "/home" },
{ "name": "@var", "mountpoint": "/var" },
{ "name": "@snapshots", "mountpoint": "/.snapshots" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": ["compress=zstd"],
"mountpoint": null,
"obj_id": "part-root",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 20398997504
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 1074790400
},
"status": "create",
"type": "primary"
}
]
}
]
},
"hostname": "gitea",
"kernels": ["linux"],
"locale_config": {
"kb_layout": "de_CH-latin1",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": ["https://geo.mirror.pkgbuild.com/$repo/os/$arch"]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"qemu-guest-agent"
],
"parallel_downloads": 5,
"script": "guided",
"services": ["sshd", "NetworkManager", "qemu-guest-agent"],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.3"
}
+108
View File
@@ -0,0 +1,108 @@
{
"archinstall-language": "English",
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [
"mkdir -p /home/adyrem/.ssh && chmod 700 /home/adyrem/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdW4rhQFv561bYs8w0/VgR6AFgOLvVsAh6pcjZ/CO2R homelab' > /home/adyrem/.ssh/authorized_keys && chmod 600 /home/adyrem/.ssh/authorized_keys && chown -R adyrem:adyrem /home/adyrem/.ssh"
],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "/dev/vda",
"wipe": true,
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": ["boot", "esp"],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "part-esp",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "name": "@", "mountpoint": "/" },
{ "name": "@home", "mountpoint": "/home" },
{ "name": "@var", "mountpoint": "/var" },
{ "name": "@snapshots", "mountpoint": "/.snapshots" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": ["compress=zstd"],
"mountpoint": null,
"obj_id": "part-root",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 41873833984
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": 1074790400
},
"status": "create",
"type": "primary"
}
]
}
]
},
"hostname": "monitoring",
"kernels": ["linux"],
"locale_config": {
"kb_layout": "de_CH-latin1",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": ["https://geo.mirror.pkgbuild.com/$repo/os/$arch"]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"qemu-guest-agent"
],
"parallel_downloads": 5,
"script": "guided",
"services": ["sshd", "NetworkManager", "qemu-guest-agent"],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.3"
}
+289
View File
@@ -0,0 +1,289 @@
# Homelab Setup Requirements
## Hardware Context
- Host machine with Intel i7-8700, 32GB RAM
- Two disks: one smaller SSD (primary), one larger WD Black HDD (secondary)
- Complete fresh install, nothing to preserve
---
## Non-Negotiable Requirements
1. Claude Code sessions can SSH into multiple VMs in the same session with full sudo access inside the VM.
2. Claude Code sessions cannot execute any commands on the host — only read and write files in designated folders.
3. A Windows VM with Visual Studio must be runnable and accessible via RDP.
---
## 1. Disk Layout
- Proxmox installed on the SSD using ZFS (single-disk pool)
- ZFS chosen for native snapshots, send/receive, and compression
- VM disk images stored in a ZFS pool on the SSD (`rpool` or a dedicated dataset)
- HDD formatted as a ZFS pool, used for:
- Receiving ZFS send/receive snapshots from SSD (local backup)
- Storage for non-critical / parked VMs
- Snapshot schedule: daily snapshots retained for 7 days, weekly retained for 4 weeks
- Snapshot tooling: `sanoid` (policy-based ZFS snapshots) + `syncoid` (ZFS send/receive to HDD)
---
## 2. Host OS
- **Proxmox VE** (latest stable release, Debian-based)
- Installed via the official Proxmox ISO — no custom OS required
- No desktop environment on the host
- Minimal additional packages — Proxmox manages the hypervisor; everything else runs in VMs
- Locale: `en_US.UTF-8`, keyboard layout: Swiss German, timezone: `Europe/Zurich`
- Hostname: `homelab`
- Automatic security updates: `unattended-upgrades` configured for security patches only; full updates manual
- Proxmox web UI accessible from local network / VPN only
---
## 3. Networking & Remote Access
### SSH
- SSH access to host is **only permitted from within the local network or VPN** — Proxmox firewall blocks SSH from WAN
- Key-based authentication only, password auth disabled
- Root login disabled (admin user only)
- A dedicated SSH keypair for this project; public key committed to repo, private key kept by operator
- Dedicated non-default SSH port — to be decided during setup
### RDP
- RDP is for VMs only — no RDP on the host
- VM RDP accessible through VPN from outside the network
### Firewall
- Proxmox built-in firewall enabled at datacenter and host level
- Default deny inbound from WAN
- Allow inbound from WAN: VPN port only
- Allow inbound from local network / VPN: SSH (host), Proxmox web UI (8006), RDP (VMs), monitoring ports (Grafana, Prometheus)
- VM-level firewall rules managed per VM in Proxmox
### VPN (Internet-Box)
- Internet-Box 3 or 4 — use **IKEv2** (supported on both; WireGuard only on 4+, cannot confirm model)
- VPN server configured on the Internet-Box itself (not on the host)
- DynDNS configured on the Internet-Box for stable external hostname
- All external access (SSH, RDP, Gitea, monitoring dashboards) routes through this VPN
- Document VPN client setup steps for Windows and Linux clients in the repo README
---
## 4. Hypervisor
- **Proxmox VE** — KVM/QEMU managed via Proxmox (not libvirt)
- VM management via Proxmox web UI and `qm` / `pvesh` CLI
- VM networks (Proxmox Linux bridges):
- `infra-net` (`vmbr1`): isolated NAT for infrastructure VMs (Gitea, monitoring)
- `dev-net` (`vmbr2`): isolated NAT for Claude Code workload VMs
- `desktop-net` (`vmbr3`): NAT for desktop VMs
- No VM has bridged access to the physical LAN unless explicitly required
- UEFI support via OVMF (included in Proxmox)
- TPM 2.0 emulation via `swtpm` (required for Windows 11)
- Infrastructure VMs (Gitea, monitoring) set to autostart on host boot via Proxmox autostart
- VM configs (`.conf` files from `/etc/pve/qemu-server/`) committed to Ansible repo
---
## 5. Users & Permissions
### Host Users
- One admin user (human operator):
- SSH key login only, no password SSH
- Full `sudo` access
- One `claude-code` service user — enforces non-negotiable requirement 2:
- **Shell set to `/usr/lib/openssh/sftp-server`** — SFTP-only, zero execution rights on host
- **Chroot or path restriction** to designated read/write folders only (e.g. `/home/claude-code/workspace`)
- SSH access restricted to a dedicated key
- No access to Proxmox tooling, VM management, or other users' files
- Cannot be used as a jump host — Claude Code SSHes directly into VMs
### VM Users (all VMs)
- `root` account: password set, SSH login disabled
- `vmuser` account: default non-root user, SSH key login, full sudo inside the VM
- Additional users may be added per VM as needed
---
## 6. Virtual Machines
### 6.1 Gitea VM (Infrastructure)
- **Distro:** Arch Linux minimal (provisioned via `archinstall` JSON config)
- **Purpose:** Self-hosted Git, mirrors all repos to GitHub automatically
- **Setup:** Provisioned manually as bootstrap step — must exist before Ansible automation can run
- **Storage:** SSD ZFS pool (infrastructure-critical)
- **Access:** SSH from host admin user; Gitea web UI accessible from local network / VPN
- **Gitea config:**
- Admin account set up manually
- Mirror job configured for every repo to sync to GitHub
- The Ansible homelab repo is the primary repo hosted here
- **Backups:** Proxmox VM snapshot + ZFS send/receive to HDD; Gitea data directory snapshotted daily
### 6.2 Monitoring VM (Infrastructure)
- **Distro:** Arch Linux minimal
- **Purpose:** Centralised observability for host and all VMs
- **Setup:** Provisioned via Ansible after Gitea bootstrap; autostart on host boot
- **Storage:** SSD ZFS pool (infrastructure-critical)
- **Access:** Grafana web UI accessible from local network / VPN only; no WAN exposure
- **Backups:** Proxmox VM snapshot + ZFS send/receive to HDD
#### Stack
| Component | Role |
|---|---|
| **Prometheus** | Metrics collection and storage |
| **Grafana** | Dashboards and alerting UI |
| **Node Exporter** | Host and VM system metrics (CPU, RAM, disk, network) |
| **Alertmanager** | Alert routing (email or other notification channel) |
| **Loki** | Log aggregation |
| **Promtail** | Log shipping agent (runs on host and each VM) |
#### What Is Monitored
- **Host system:** CPU, RAM, disk usage/IO, network throughput, ZFS pool health, Proxmox VM states
- **All VMs:** CPU, RAM, disk, network via Node Exporter installed in each VM by Ansible
- **Gitea VM:** Service health, disk usage of repo storage
- **Windows VM:** Basic ping/availability check only (no agent)
- **ZFS snapshots:** Alert if daily snapshot has not run within expected window
- **VM availability:** Alert if any infrastructure VM (Gitea, monitoring itself) goes unreachable
- **Disk space:** Alert at 75% and 90% on SSD and HDD ZFS pools
#### Alerting
- Alertmanager configured with at least one notification channel (email recommended as default)
- Alert on: host down, infrastructure VM down, disk >75%, snapshot missed, high CPU/RAM sustained >15 min
#### Ansible Integration
- Node Exporter and Promtail installed and enabled on every Linux VM by the base VM playbook
- Prometheus scrape config updated automatically when a new VM is provisioned
- Monitoring VM definition and Prometheus/Grafana configs committed to Ansible repo
- Grafana dashboards exported as JSON and committed to repo for full reproducibility
### 6.3 Windows VM
- **Purpose:** Visual Studio development (extension building) — non-negotiable requirement 3
- **License:** Activated via personal Microsoft account
- **UEFI + TPM:** OVMF + swtpm for Windows 11 compatibility
- **Resources:** 8 cores, 16GB RAM (adjustable)
- **Storage:** SSD ZFS pool (active use); snapshot to HDD after each milestone
- **Snapshot strategy:** ZFS snapshot of VM disk taken after:
1. Clean Windows install + activation
2. Visual Studio install + configuration
- Snapshots sent to HDD ZFS pool; rebuild = `zfs receive` from HDD
- **Access:** RDP via VPN from outside network
- **Network:** `desktop-net`, no access to host or other VM networks
- **Monitoring:** Ping/availability check only
### 6.4 Fedora KDE VM
- **Purpose:** General purpose Linux desktop
- **Distro:** Fedora (latest stable) with KDE Plasma desktop
- **Resources:** 4 cores, 8GB RAM (adjustable)
- **Storage:** HDD ZFS pool (non-critical)
- **Users:** `root` + `vmuser`
- **Access:** RDP via `xrdp` or VNC; SSH
- **Network:** `desktop-net`
- **Monitoring:** Node Exporter + Promtail installed via Ansible
### 6.5 Arch Desktop VM
- **Purpose:** General purpose Linux desktop
- **Distro:** Arch Linux with desktop environment (to be chosen at setup time)
- **Resources:** 4 cores, 8GB RAM (adjustable)
- **Storage:** HDD ZFS pool (non-critical)
- **Users:** `root` + `vmuser`
- **Access:** RDP or VNC; SSH
- **Network:** `desktop-net`
- **Monitoring:** Node Exporter + Promtail installed via Ansible
### 6.6 Claude Code Dev VMs
- **Purpose:** Isolated environments for Claude Code to operate in — enforces non-negotiable requirement 1
- **Distro:** Arch Linux minimal (provisioned via `archinstall` JSON config)
- **Provisioning:** Cloned from a base template VM, then configured via Ansible
- **Persistence:** VMs are persistent (not ephemeral per session)
- **Multi-VM sessions:** Claude Code may SSH into multiple dev VMs in one session
- **Isolation requirements:**
- No shared folders with host
- No access to `infra-net` or `desktop-net`
- Operates on `dev-net` only
- No route to host system
- `vmuser` is the SSH target for Claude Code with full sudo inside the VM
- **Resources:** 2 cores, 4GB RAM per VM (adjustable)
- **Storage:** SSD ZFS pool for active VMs, HDD ZFS pool for parked/unused ones
- **Monitoring:** Node Exporter + Promtail installed via Ansible base VM playbook
---
## 7. Ansible Automation
- All post-install configuration managed via Ansible playbooks
- Repo hosted on Gitea VM, mirrored to GitHub
- Secrets managed via `ansible-vault`; vault password stored only by the operator
- **Playbook structure:**
```
homelab/
├── archinstall-config.json # reproducible base config for Linux guest VMs
├── inventory.ini # host + VM inventory
├── playbooks/
│ ├── host.yml # Proxmox host: users, SSH, firewall, sanoid/syncoid
│ ├── snapshots.yml # ZFS snapshot schedules (sanoid) + send/receive (syncoid)
│ ├── users.yml # host users, SSH keys, claude-code SFTP restriction
│ ├── monitoring.yml # monitoring VM, Prometheus, Grafana, Loki
│ └── vms/
│ ├── gitea.yml
│ ├── windows.yml
│ ├── fedora-kde.yml
│ ├── arch-desktop.yml
│ └── dev-template.yml
├── templates/ # Jinja2 config templates
│ ├── prometheus.yml.j2
│ ├── promtail.yml.j2
│ ├── alertmanager.yml.j2
│ └── sanoid.conf.j2
├── files/
│ └── grafana-dashboards/ # exported dashboard JSON files
├── vars/
│ └── vault.yml # encrypted secrets
└── README.md
```
- Every playbook must be idempotent (safe to re-run)
- README must document:
- Full rebuild procedure from bare metal
- VPN client setup (Windows + Linux)
- How to provision a new dev VM
- How to restore a VM from ZFS snapshot
- How to access the Proxmox web UI and Grafana dashboard
- How to add a new VM to monitoring
---
## 8. Backup & Recovery
- **Primary backup mechanism:** `syncoid` ZFS send/receive from SSD pool to HDD pool
- **Snapshot policy:** managed by `sanoid` on both pools
- **Scope of backups:**
- All VM disks on SSD ZFS pool (daily incremental send/receive to HDD)
- Gitea data directory (daily snapshot)
- Monitoring VM data directory including Prometheus data (daily snapshot)
- Grafana dashboards as JSON in git repo (always up to date)
- Windows VM disk snapshot after each milestone (stored on HDD ZFS pool)
- **No external/cloud backup initially** — architecture must make it easy to add (e.g. `restic` to Backblaze B2) later
- **Recovery procedure documented in README**
---
## 9. Rebuild Order (Disaster Recovery Sequence)
In the event of full hardware loss, the rebuild order is:
1. Install Proxmox on new hardware (ISO installer, ~10 min)
2. Restore SSD ZFS pool from HDD send/receive backups (`zfs receive`)
3. Run `host.yml` Ansible playbook (cloned from GitHub mirror) to configure host users, SSH, firewall
4. Start Gitea VM (restored from ZFS snapshot) and clone Ansible repo from it
5. Run `monitoring.yml` to restore monitoring VM
6. Run remaining playbooks to restore all VMs
7. Import Grafana dashboards from JSON files in repo
8. Reconfigure Internet-Box VPN and DynDNS if hardware changed
---
## 10. Out of Scope (for now)
- External/cloud backups (architecture must not preclude adding later)
- Additional VM distros beyond those listed — template mechanism should make adding easy
- Monitoring of Windows internals beyond availability ping
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# Run in the Arch live environment to generate archinstall-config-physical.json
# with correct partition sizes for the target SSD.
#
# Usage:
# ./generate-physical-config.sh /dev/sda # outputs to /tmp/config.json
# ./generate-physical-config.sh /dev/nvme0n1p0 # NVMe example
set -euo pipefail
info() { echo -e "\033[1;34m==>\033[0m $*"; }
die() { echo -e "\033[1;31mERROR:\033[0m $*" >&2; exit 1; }
DEVICE="${1:-}"
if [[ -z "$DEVICE" ]]; then
echo "Usage: $0 <ssd-device>"
echo
echo "Available disks:"
lsblk -d -o NAME,SIZE,MODEL
exit 1
fi
[[ -b "$DEVICE" ]] || die "Device '$DEVICE' not found or is not a block device."
# ---------------------------------------------------------------------------
# Partition layout
# ---------------------------------------------------------------------------
# Layout:
# [1 MiB gap] [1 GiB ESP /boot] [rest: BTRFS root with subvolumes]
#
START_PADDING_B=1048576 # 1 MiB
ESP_B=1073741824 # 1 GiB
END_PADDING_B=1048576 # 1 MiB — leave 1 MiB at end for partition table alignment
ROOT_START_B=$(( START_PADDING_B + ESP_B )) # = 1074790400
DISK_BYTES=$(lsblk -b -d -n -o SIZE "$DEVICE")
ROOT_SIZE_B=$(( DISK_BYTES - ROOT_START_B - END_PADDING_B ))
info "Device: $DEVICE"
info "Disk size: $DISK_BYTES bytes ($(( DISK_BYTES / 1024 / 1024 / 1024 )) GiB)"
info "ESP start: $START_PADDING_B size: $ESP_B"
info "Root start: $ROOT_START_B size: $ROOT_SIZE_B"
(( ROOT_SIZE_B > 0 )) || die "Disk is too small to partition."
OUT="/tmp/config.json"
cat > "$OUT" << EOF
{
"app_config": null,
"archinstall-language": "English",
"auth_config": {},
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [
"mkdir -p /home/adyrem/.ssh && chmod 700 /home/adyrem/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdW4rhQFv561bYs8w0/VgR6AFgOLvVsAh6pcjZ/CO2R homelab' > /home/adyrem/.ssh/authorized_keys && chmod 600 /home/adyrem/.ssh/authorized_keys && chown -R adyrem:adyrem /home/adyrem/.ssh",
"mkdir -p /vms/infra /vms/dev"
],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "$DEVICE",
"wipe": true,
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": ["boot", "esp"],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "part-esp",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "name": "@", "mountpoint": "/" },
{ "name": "@home", "mountpoint": "/home" },
{ "name": "@var", "mountpoint": "/var" },
{ "name": "@snapshots", "mountpoint": "/.snapshots" },
{ "name": "@vms-infra", "mountpoint": "/vms/infra" },
{ "name": "@vms-dev", "mountpoint": "/vms/dev" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": ["compress=zstd", "noatime"],
"mountpoint": null,
"obj_id": "part-root",
"size": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": $ROOT_SIZE_B
},
"start": {
"sector_size": { "unit": "B", "value": 512 },
"unit": "B",
"value": $ROOT_START_B
},
"status": "create",
"type": "primary"
}
]
}
]
},
"hostname": "homelab",
"kernels": ["linux"],
"locale_config": {
"kb_layout": "de_CH-latin1",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": ["https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"btrbk",
"nftables"
],
"parallel_downloads": 5,
"script": "guided",
"services": ["sshd", "NetworkManager", "nftables"],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.1",
"users": [
{
"enc_password": "\$y\$j9T\$pqMooSvLvxBg2YP7eCQlq/\$Ae8.dvSYB817bQkj5w9S4wu9OtWIq.p/pbWY6A/ythD",
"groups": ["wheel"],
"sudo": true,
"username": "adyrem"
}
]
}
EOF
echo
echo "Config written to $OUT"
echo "Run: archinstall --config $OUT"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run this inside the Arch live environment on physical hardware.
# Fetches the config generator from the host HTTP server, generates the config
# for the correct SSD device, then starts archinstall.
#
# Usage:
# curl http://<host-ip>:8080/scripts/physical-install.sh | bash
# or with device override:
# curl http://<host-ip>:8080/scripts/physical-install.sh -o /tmp/install.sh
# bash /tmp/install.sh /dev/sda
set -euo pipefail
HOST="${HOST:-http://192.168.1.1:8080}" # update to your host's LAN IP
DEVICE="${1:-}"
echo "==> Available disks:"
lsblk -d -o NAME,SIZE,MODEL
echo
if [[ -z "$DEVICE" ]]; then
read -rp "Enter SSD device for OS install (e.g. /dev/sda): " DEVICE
fi
echo "==> Fetching config generator..."
curl -fsSL "$HOST/scripts/generate-physical-config.sh" -o /tmp/generate-config.sh
chmod +x /tmp/generate-config.sh
echo "==> Generating config for $DEVICE..."
/tmp/generate-config.sh "$DEVICE"
echo "==> Starting archinstall..."
archinstall --config /tmp/config.json
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# setup-arch-vm.sh
# Creates a minimal Arch Linux VM on the local Fedora host using KVM/libvirt.
# VM disk images are stored under homelab/vms/.
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
VM_DIR="$PROJECT_DIR/vms"
ISO_DIR="$VM_DIR/iso"
VM_NAME="arch-base"
VM_DISK="$VM_DIR/${VM_NAME}.qcow2"
VM_RAM=2048 # MiB
VM_CPUS=2
VM_DISK_SIZE=20 # GiB
ARCH_ISO_URL="https://geo.mirror.pkgbuild.com/iso/2026.04.01/archlinux-2026.04.01-x86_64.iso"
ARCH_ISO_SHA256="f14bf46afbe782d28835aed99bfa2fe447903872cb9f4b21153196d6ed1d48ae"
ARCH_ISO="$ISO_DIR/archlinux-2026.04.01-x86_64.iso"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info() { echo -e "\033[1;34m==>\033[0m $*"; }
ok() { echo -e "\033[1;32m ✓\033[0m $*"; }
warn() { echo -e "\033[1;33m !\033[0m $*"; }
die() { echo -e "\033[1;31mERROR:\033[0m $*" >&2; exit 1; }
require_cmd() { command -v "$1" &>/dev/null || die "'$1' not found. Install it and re-run."; }
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
info "Running pre-flight checks..."
require_cmd virsh
require_cmd virt-install
require_cmd qemu-img
require_cmd sha256sum
# KVM modules — load if not already present
if ! lsmod | grep -q '^kvm'; then
info "KVM modules not loaded — attempting to load..."
if grep -q "GenuineIntel" /proc/cpuinfo 2>/dev/null; then
sudo modprobe kvm_intel || die "Failed to load kvm_intel. Enable virtualisation in BIOS/UEFI."
else
sudo modprobe kvm_amd || die "Failed to load kvm_amd. Enable virtualisation in BIOS/UEFI."
fi
fi
ok "KVM modules loaded"
# br_netfilter — required for virtnetworkd to configure bridge netfilter rules;
# without it /proc/sys/net/bridge/ doesn't exist and net-start fails with EPERM
if ! lsmod | grep -q '^br_netfilter'; then
info "Loading br_netfilter module..."
sudo modprobe br_netfilter || die "Failed to load br_netfilter kernel module."
fi
# Persist across reboots
if [[ ! -f /etc/modules-load.d/libvirt.conf ]]; then
echo -e "kvm_intel\nbr_netfilter" | sudo tee /etc/modules-load.d/libvirt.conf >/dev/null
fi
ok "br_netfilter loaded"
# libvirt daemons — Fedora uses modular socket-activated daemons, not monolithic libvirtd
LIBVIRT_SOCKETS=(virtqemud virtnetworkd virtnodedevd virtstoraged virtsecretd)
info "Enabling libvirt modular daemons..."
for svc in "${LIBVIRT_SOCKETS[@]}"; do
if ! systemctl is-active --quiet "${svc}.socket"; then
sudo systemctl enable --now "${svc}.socket"
fi
# Remove the idle timeout so daemons don't shut down mid-operation
if [[ ! -f /etc/systemd/system/${svc}.service.d/notimeout.conf ]]; then
sudo mkdir -p "/etc/systemd/system/${svc}.service.d"
printf '[Service]\nEnvironment=%s_ARGS=\n' "${svc^^}" \
| sudo tee "/etc/systemd/system/${svc}.service.d/notimeout.conf" >/dev/null
fi
if ! systemctl is-active --quiet "${svc}.service"; then
sudo systemctl start "${svc}.service" 2>/dev/null || true
fi
done
sudo systemctl daemon-reload
ok "libvirt modular daemons ready"
# Group membership — required for polkit to allow bridge/network operations
if ! id -nG | grep -qw libvirt; then
info "Adding $USER to libvirt and kvm groups..."
sudo usermod -aG libvirt,kvm "$USER"
echo
echo " ┌─────────────────────────────────────────────────────────┐"
echo " │ Group membership updated. You must start a new shell │"
echo " │ for the change to take effect, then re-run the script. │"
echo " │ │"
echo " │ Run: newgrp libvirt │"
echo " │ Then: ./scripts/setup-arch-vm.sh │"
echo " └─────────────────────────────────────────────────────────┘"
exit 0
fi
ok "User is in libvirt group"
VIRSH="virsh --connect qemu:///system"
VIRT_INSTALL="virt-install --connect qemu:///system"
QEMU_IMG="qemu-img"
# ---------------------------------------------------------------------------
# Default network
# ---------------------------------------------------------------------------
info "Checking libvirt default network..."
if ! $VIRSH net-list --all | grep -q 'default'; then
info "Default network not defined — creating it..."
$VIRSH net-define /usr/share/libvirt/networks/default.xml \
|| die "Could not define default network. Is libvirt-daemon-config-network installed?"
fi
if ! $VIRSH net-list | grep -q 'default'; then
# Remove a stale virbr0 left over from a previous failed start
if ip link show virbr0 &>/dev/null; then
info "Removing stale virbr0 bridge..."
sudo ip link set virbr0 down 2>/dev/null || true
sudo ip link delete virbr0 2>/dev/null || true
fi
$VIRSH net-start default || true # ignore "already active" race
ok "Default network started"
fi
$VIRSH net-autostart default &>/dev/null || true
ok "Default network ready"
# ---------------------------------------------------------------------------
# Directory setup
# ---------------------------------------------------------------------------
info "Creating VM storage directories..."
mkdir -p "$VM_DIR" "$ISO_DIR"
# Grant the qemu user search (execute) permission on every directory component
# leading to the VM storage — QEMU runs as uid qemu and can't traverse home dirs.
if command -v setfacl &>/dev/null; then
dir="$VM_DIR"
while [[ "$dir" != "/" ]]; do
setfacl -m u:qemu:x "$dir" 2>/dev/null || sudo setfacl -m u:qemu:x "$dir"
dir="$(dirname "$dir")"
done
# Also allow qemu to read/write inside the vms directory itself
setfacl -m u:qemu:rwx "$VM_DIR" 2>/dev/null || sudo setfacl -m u:qemu:rwx "$VM_DIR"
setfacl -m u:qemu:rx "$ISO_DIR" 2>/dev/null || sudo setfacl -m u:qemu:rx "$ISO_DIR"
else
warn "setfacl not found — install 'acl' package if QEMU reports permission denied."
fi
ok "Directories ready: $VM_DIR"
# ---------------------------------------------------------------------------
# ISO download
# ---------------------------------------------------------------------------
if [[ -f "$ARCH_ISO" ]]; then
info "ISO already present — verifying checksum..."
echo "$ARCH_ISO_SHA256 $ARCH_ISO" | sha256sum --check --quiet \
&& ok "Checksum OK" \
|| { warn "Checksum mismatch — re-downloading ISO..."; rm -f "$ARCH_ISO"; }
fi
if [[ ! -f "$ARCH_ISO" ]]; then
info "Downloading Arch Linux ISO (~1.1 GB)..."
curl -L --progress-bar -o "$ARCH_ISO" "$ARCH_ISO_URL"
info "Verifying checksum..."
echo "$ARCH_ISO_SHA256 $ARCH_ISO" | sha256sum --check --quiet \
|| die "ISO checksum verification failed. File may be corrupt."
ok "ISO downloaded and verified"
fi
# ---------------------------------------------------------------------------
# VM disk image
# ---------------------------------------------------------------------------
if [[ -f "$VM_DISK" ]]; then
warn "Disk image already exists at $VM_DISK"
read -rp " Delete and recreate? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || die "Aborting — existing disk left intact."
rm -f "$VM_DISK"
fi
info "Creating ${VM_DISK_SIZE}G qcow2 disk image..."
$QEMU_IMG create -f qcow2 "$VM_DISK" "${VM_DISK_SIZE}G" -q
ok "Disk image created: $VM_DISK"
# ---------------------------------------------------------------------------
# VM definition
# ---------------------------------------------------------------------------
if $VIRSH dominfo "$VM_NAME" &>/dev/null; then
warn "VM '$VM_NAME' already defined in libvirt."
read -rp " Undefine and recreate? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || die "Aborting — existing VM left intact."
$VIRSH destroy "$VM_NAME" 2>/dev/null || true
$VIRSH undefine "$VM_NAME" --remove-all-storage 2>/dev/null || true
fi
# Verify virbr0 is actually up — virtnetworkd may have timed out and torn it down
if ! ip link show virbr0 &>/dev/null; then
info "virbr0 is gone — restarting default network..."
sudo systemctl restart virtnetworkd.service
sleep 2
$VIRSH net-start default 2>/dev/null || true
ip link show virbr0 &>/dev/null || die "virbr0 still missing after restart — check virtnetworkd logs."
fi
OVMF_CODE="/usr/share/edk2/ovmf/OVMF_CODE.fd"
OVMF_VARS="/usr/share/edk2/ovmf/OVMF_VARS.fd"
info "Defining VM '$VM_NAME'..."
$VIRT_INSTALL \
--name "$VM_NAME" \
--ram "$VM_RAM" \
--vcpus "$VM_CPUS" \
--cpu host-passthrough \
--os-variant archlinux \
--disk "path=$VM_DISK,format=qcow2,bus=virtio,cache=writeback" \
--disk "path=$ARCH_ISO,device=cdrom,readonly=on,bus=sata" \
--network network=default,model=virtio \
--graphics spice,listen=127.0.0.1 \
--video qxl \
--boot "loader=${OVMF_CODE},loader.readonly=yes,loader.type=pflash,nvram.template=${OVMF_VARS},cdrom,hd" \
--noautoconsole
ok "VM '$VM_NAME' defined and started"
# ---------------------------------------------------------------------------
# Get VM IP (may take a moment for DHCP)
# ---------------------------------------------------------------------------
info "Waiting for VM to get a DHCP address (up to 60s)..."
VM_IP=""
for i in $(seq 1 12); do
sleep 5
VM_IP=$($VIRSH domifaddr "$VM_NAME" 2>/dev/null | grep -oP '\d+\.\d+\.\d+\.\d+' | head -1 || true)
[[ -n "$VM_IP" ]] && break
echo -n "."
done
echo
if [[ -n "$VM_IP" ]]; then
ok "VM IP address: $VM_IP"
else
warn "Could not detect VM IP yet — it may still be booting."
warn "Run: virsh domifaddr $VM_NAME to check later."
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " VM '$VM_NAME' is booting into the Arch Linux live environment."
echo
echo " Open the console in virt-manager, then run:"
echo
echo " 1. On your host — serve the config (if not already running):"
echo " cd ~/dev/homelab && python3 -m http.server 8080"
echo
echo " 2. In the VM console — one-liner to fetch config and start install:"
echo " curl http://192.168.122.1:8080/scripts/vm-install.sh | bash"
echo
echo " 3. After install completes, select Reboot."
echo " SSH will be available on the installed system:"
if [[ -n "$VM_IP" ]]; then
echo " ssh -i ~/dev/homelab/keys/homelab_ed25519 adyrem@$VM_IP"
fi
echo
echo " Arch ISO: $ARCH_ISO"
echo " Disk image: $VM_DISK"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Run this inside the Arch live environment to fetch the config and start archinstall.
# Usage: curl http://192.168.122.1:8080/scripts/vm-install.sh | bash
set -euo pipefail
HOST="http://192.168.122.1:8080"
echo "==> Fetching archinstall config..."
curl -fsSL "$HOST/archinstall-config.json" -o /tmp/config.json
echo "==> Starting archinstall..."
archinstall --config /tmp/config.json
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
VM_NAME="arch-base"
virsh --connect qemu:///system change-media "$VM_NAME" sda --eject 2>/dev/null && echo "ISO ejected" || echo "No ISO to eject"
virsh --connect qemu:///system reboot "$VM_NAME"
echo "Rebooting $VM_NAME..."
+146
View File
@@ -0,0 +1,146 @@
{
"app_config": null,
"archinstall-language": "English",
"auth_config": {},
"bootloader_config": {
"bootloader": "Systemd-boot",
"removable": true,
"uki": false
},
"custom_commands": [],
"disk_config": {
"btrfs_options": {
"snapshot_config": null
},
"config_type": "manual_partitioning",
"device_modifications": [
{
"device": "/dev/vda",
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": [
"boot",
"esp"
],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "3cbd14eb-4979-4273-a987-ca34d612c421",
"size": {
"sector_size": {
"unit": "B",
"value": 512
},
"unit": "GiB",
"value": 1
},
"start": {
"sector_size": {
"unit": "B",
"value": 512
},
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [
{
"mountpoint": "/",
"name": "@"
},
{
"mountpoint": "/home",
"name": "@home"
},
{
"mountpoint": "/var",
"name": "@var"
},
{
"mountpoint": "/.snapshots",
"name": "@snapshots"
}
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": [
"compress=zstd"
],
"mountpoint": "/",
"obj_id": "52dfb6b1-f569-4499-8dbf-0fde2e900819",
"size": {
"sector_size": {
"unit": "B",
"value": 512
},
"unit": "B",
"value": 20398997504
},
"start": {
"sector_size": {
"unit": "B",
"value": 512
},
"unit": "B",
"value": 1074790400
},
"status": "create",
"type": "primary"
}
],
"wipe": true
}
]
},
"hostname": "arch-base",
"kernels": [
"linux"
],
"locale_config": {
"kb_layout": "ch",
"sys_enc": "UTF-8",
"sys_lang": "en_US"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [],
"mirror_regions": {
"Worldwide": [
"https://geo.mirror.pkgbuild.com/$repo/os/$arch"
]
},
"optional_repositories": []
},
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": [
"base-devel",
"git",
"openssh",
"networkmanager",
"vim",
"btrbk",
"qemu-guest-agent"
],
"parallel_downloads": 0,
"script": "guided",
"services": [
"sshd",
"NetworkManager",
"qemu-guest-agent"
],
"swap": {
"algorithm": "zstd",
"enabled": false
},
"timezone": "Europe/Zurich",
"version": "4.1"
}