From 39be9fafe764206e2e6dd6f250079140f2ec6c98 Mon Sep 17 00:00:00 2001 From: Adyrem Date: Wed, 13 May 2026 17:34:03 +0200 Subject: [PATCH] Add Gitea Actions runner, ansible-lint CI, vault setup, and fix group_vars layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.json | 11 + .gitea/workflows/lint.yml | 24 ++ .gitignore | 3 + ansible/ansible.cfg | 4 +- .../group_vars/all/vars.yml} | 0 ansible/inventory/group_vars/all/vault.yml | 30 ++ .../group_vars/infra/vars.yml} | 0 ansible/playbooks/gitea.yml | 1 + ansible/roles/gitea/defaults/main.yml | 10 +- ansible/roles/gitea/tasks/main.yml | 1 + ansible/roles/gitea/templates/app.ini.j2 | 3 + ansible/roles/gitea_runner/defaults/main.yml | 6 + ansible/roles/gitea_runner/handlers/main.yml | 5 + ansible/roles/gitea_runner/tasks/main.yml | 106 +++++++ archinstall-config-physical.json | 122 ++++++++ archinstall-config.json | 119 ++++++++ archinstall-configs/gitea.json | 108 +++++++ archinstall-configs/monitoring.json | 108 +++++++ homelab-requirements.md | 289 ++++++++++++++++++ scripts/generate-physical-config.sh | 174 +++++++++++ scripts/physical-install.sh | 32 ++ scripts/setup-arch-vm.sh | 268 ++++++++++++++++ scripts/vm-install.sh | 12 + scripts/vm-reboot.sh | 8 + user_configuration.json | 146 +++++++++ 25 files changed, 1584 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .gitea/workflows/lint.yml rename ansible/{group_vars/all.yml => inventory/group_vars/all/vars.yml} (100%) create mode 100644 ansible/inventory/group_vars/all/vault.yml rename ansible/{group_vars/infra.yml => inventory/group_vars/infra/vars.yml} (100%) create mode 100644 ansible/roles/gitea_runner/defaults/main.yml create mode 100644 ansible/roles/gitea_runner/handlers/main.yml create mode 100644 ansible/roles/gitea_runner/tasks/main.yml create mode 100644 archinstall-config-physical.json create mode 100644 archinstall-config.json create mode 100644 archinstall-configs/gitea.json create mode 100644 archinstall-configs/monitoring.json create mode 100644 homelab-requirements.md create mode 100755 scripts/generate-physical-config.sh create mode 100755 scripts/physical-install.sh create mode 100755 scripts/setup-arch-vm.sh create mode 100644 scripts/vm-install.sh create mode 100755 scripts/vm-reboot.sh create mode 100644 user_configuration.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..be74b5a --- /dev/null +++ b/.claude/settings.json @@ -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:* *)" + ] + } +} diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml new file mode 100644 index 0000000..430d6b5 --- /dev/null +++ b/.gitea/workflows/lint.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7331c13..ef97fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ wireguard/ *.vault *.secret ansible/vault_pass +user_credentials.json +archinstall-configs/creds.json +vms/ diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index ace273d..3e5e924 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -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 diff --git a/ansible/group_vars/all.yml b/ansible/inventory/group_vars/all/vars.yml similarity index 100% rename from ansible/group_vars/all.yml rename to ansible/inventory/group_vars/all/vars.yml diff --git a/ansible/inventory/group_vars/all/vault.yml b/ansible/inventory/group_vars/all/vault.yml new file mode 100644 index 0000000..c5c166d --- /dev/null +++ b/ansible/inventory/group_vars/all/vault.yml @@ -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 diff --git a/ansible/group_vars/infra.yml b/ansible/inventory/group_vars/infra/vars.yml similarity index 100% rename from ansible/group_vars/infra.yml rename to ansible/inventory/group_vars/infra/vars.yml diff --git a/ansible/playbooks/gitea.yml b/ansible/playbooks/gitea.yml index e6b5bf2..7f6446a 100644 --- a/ansible/playbooks/gitea.yml +++ b/ansible/playbooks/gitea.yml @@ -5,3 +5,4 @@ - node_exporter - promtail - gitea + - gitea_runner diff --git a/ansible/roles/gitea/defaults/main.yml b/ansible/roles/gitea/defaults/main.yml index ce00a2b..989564b 100644 --- a/ansible/roles/gitea/defaults/main.yml +++ b/ansible/roles/gitea/defaults/main.yml @@ -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 }}" diff --git a/ansible/roles/gitea/tasks/main.yml b/ansible/roles/gitea/tasks/main.yml index c3ab785..a65af75 100644 --- a/ansible/roles/gitea/tasks/main.yml +++ b/ansible/roles/gitea/tasks/main.yml @@ -4,6 +4,7 @@ name: - gitea - postgresql + - python-psycopg2 state: present - name: Initialize PostgreSQL cluster diff --git a/ansible/roles/gitea/templates/app.ini.j2 b/ansible/roles/gitea/templates/app.ini.j2 index 2b36069..83897a5 100644 --- a/ansible/roles/gitea/templates/app.ini.j2 +++ b/ansible/roles/gitea/templates/app.ini.j2 @@ -45,6 +45,9 @@ PROVIDER = db [cache] ADAPTER = memory +[actions] +ENABLED = true + [indexer] ISSUE_INDEXER_TYPE = bleve REPO_INDEXER_ENABLED = true diff --git a/ansible/roles/gitea_runner/defaults/main.yml b/ansible/roles/gitea_runner/defaults/main.yml new file mode 100644 index 0000000..3244777 --- /dev/null +++ b/ansible/roles/gitea_runner/defaults/main.yml @@ -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 }}" diff --git a/ansible/roles/gitea_runner/handlers/main.yml b/ansible/roles/gitea_runner/handlers/main.yml new file mode 100644 index 0000000..6f978cd --- /dev/null +++ b/ansible/roles/gitea_runner/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart act_runner + ansible.builtin.systemd: + name: act_runner + state: restarted diff --git a/ansible/roles/gitea_runner/tasks/main.yml b/ansible/roles/gitea_runner/tasks/main.yml new file mode 100644 index 0000000..bce5755 --- /dev/null +++ b/ansible/roles/gitea_runner/tasks/main.yml @@ -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 diff --git a/archinstall-config-physical.json b/archinstall-config-physical.json new file mode 100644 index 0000000..da82bef --- /dev/null +++ b/archinstall-config-physical.json @@ -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" + } + ] +} diff --git a/archinstall-config.json b/archinstall-config.json new file mode 100644 index 0000000..fd9c530 --- /dev/null +++ b/archinstall-config.json @@ -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" + } + ] +} diff --git a/archinstall-configs/gitea.json b/archinstall-configs/gitea.json new file mode 100644 index 0000000..983e2ba --- /dev/null +++ b/archinstall-configs/gitea.json @@ -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" +} diff --git a/archinstall-configs/monitoring.json b/archinstall-configs/monitoring.json new file mode 100644 index 0000000..7a5b98a --- /dev/null +++ b/archinstall-configs/monitoring.json @@ -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" +} diff --git a/homelab-requirements.md b/homelab-requirements.md new file mode 100644 index 0000000..476b9ca --- /dev/null +++ b/homelab-requirements.md @@ -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 diff --git a/scripts/generate-physical-config.sh b/scripts/generate-physical-config.sh new file mode 100755 index 0000000..421329d --- /dev/null +++ b/scripts/generate-physical-config.sh @@ -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 " + 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" diff --git a/scripts/physical-install.sh b/scripts/physical-install.sh new file mode 100755 index 0000000..ec503dc --- /dev/null +++ b/scripts/physical-install.sh @@ -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://:8080/scripts/physical-install.sh | bash +# or with device override: +# curl http://: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 diff --git a/scripts/setup-arch-vm.sh b/scripts/setup-arch-vm.sh new file mode 100755 index 0000000..80f76cf --- /dev/null +++ b/scripts/setup-arch-vm.sh @@ -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 diff --git a/scripts/vm-install.sh b/scripts/vm-install.sh new file mode 100644 index 0000000..cb46a7e --- /dev/null +++ b/scripts/vm-install.sh @@ -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 diff --git a/scripts/vm-reboot.sh b/scripts/vm-reboot.sh new file mode 100755 index 0000000..3404be5 --- /dev/null +++ b/scripts/vm-reboot.sh @@ -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..." diff --git a/user_configuration.json b/user_configuration.json new file mode 100644 index 0000000..f98a6d2 --- /dev/null +++ b/user_configuration.json @@ -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" +} \ No newline at end of file