Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Answer files embed the root password hash + host network config — keep out of git.
|
||||
# They're regenerated from the eventual Ansible role / vault.
|
||||
pxe/answer/*.toml
|
||||
pxe/secrets.env
|
||||
|
||||
# Build scratch (large PXE artifacts live on the netboot host, not here)
|
||||
pxe/build/
|
||||
vyos/credentials.yml
|
||||
@@ -0,0 +1,94 @@
|
||||
# Proxmox HA — and the watchdog that makes it real
|
||||
|
||||
HA was enabled for **one** guest on 2026-07-26. The cluster's posture is otherwise
|
||||
unchanged: no HA, guests disposable.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| resource | `vm:100` (`vyos-rtr`), `state started`, `max_restart 3`, `max_relocate 2` |
|
||||
| why | it is the gateway for **both** SDN VNets and an OSPF speaker; losing it takes `labnet` + `retronet` offline (the main LAN is unaffected — the NEC IX is its gateway) |
|
||||
| storage | `pve-rg` (DRBD, place-count 2): `pve1` + `pve2` hold **UpToDate diskful** replicas, `pve3` attaches **diskless** — so it can start on any node |
|
||||
| codified | `ansible/roles/pve_ha` + `ansible/ha.yml` (separate from `site.yml`, like `cluster.yml`) |
|
||||
|
||||
**What this buys, precisely:** crash *restart* on another node, roughly **1–3 minutes**
|
||||
of gateway downtime. It is **not** seamless failover. Real gateway HA is a second VyOS
|
||||
with VRRP owning `10.60.0.1`/`10.61.0.1`; that was considered and deferred.
|
||||
|
||||
**What it costs:** adding any HA resource **arms fencing cluster-wide**. A node that
|
||||
loses quorum now self-reboots. Corosync here runs a **single ring** on the flat 1G LAN,
|
||||
so a network blip is now a reboot rather than a shrug.
|
||||
|
||||
Also set at the same time: `qm set 100 --agent 0`. PVE had `agent: enabled=1` while
|
||||
VyOS 2025.11 ships **no `qemu-ga` binary and no apt sources** — the virtio channel was
|
||||
wired but nothing could answer, so snapshots could not filesystem-freeze and no guest
|
||||
IPs were reported. Installing it would mean adding Debian repos to the only router, and
|
||||
would be wiped by the next `add system image`.
|
||||
|
||||
## ⚠ The watchdog is still `softdog` — finish this
|
||||
|
||||
`softdog` is a **software** watchdog: a kernel timer. **It cannot fire when the kernel
|
||||
itself is frozen**, which is exactly the failure this cluster has actually had (pve2's
|
||||
Raven Ridge idle freeze). So today's HA covers clean crashes, power loss and network
|
||||
partition — but *not* the freeze case that motivated it.
|
||||
|
||||
Hardware watchdogs are present and verified (2026-07-26):
|
||||
|
||||
| node | module | timeout |
|
||||
|---|---|---|
|
||||
| pve1 (Intel i3-6100U) | `iTCO_wdt` | 30s |
|
||||
| pve2 / pve3 (Ryzen 2400GE) | `sp5100_tco` | 60s |
|
||||
|
||||
`ansible/ha.yml` already writes the config (`WATCHDOG_MODULE` + a `blacklist softdog`
|
||||
in `/etc/modprobe.d/pve-ha-watchdog.conf`). **Both are needed**: `watchdog-mux` opens
|
||||
`/dev/watchdog`, which belongs to whichever watchdog registered *first*, and PVE loads
|
||||
softdog at boot — so setting `WATCHDOG_MODULE` alone leaves the hardware module sitting
|
||||
unused as `watchdog1`.
|
||||
|
||||
It takes effect on **the next reboot of each node**. The play deliberately does not
|
||||
reboot: a surprise rolling reboot of all three nodes is not something an idempotent
|
||||
baseline run should do.
|
||||
|
||||
### Live swap, without rebooting (do it in this order)
|
||||
|
||||
The danger is stopping `watchdog-mux` while an LRM holds the watchdog — that self-fences
|
||||
the node. Parking the resource first removes every active LRM, which makes the rest safe.
|
||||
|
||||
```bash
|
||||
# 1. park the resource; all LRMs go idle and release their watchdogs
|
||||
ha-manager set vm:100 --state ignored
|
||||
# wait until NO line says "watchdog active" — vm:100 keeps running throughout
|
||||
ha-manager status
|
||||
|
||||
# 2. per node (pve3, pve2, pve1 — least critical first):
|
||||
systemctl stop watchdog-mux
|
||||
rmmod softdog
|
||||
modprobe -r iTCO_wdt 2>/dev/null; modprobe iTCO_wdt # pve1
|
||||
# modprobe -r sp5100_tco; modprobe sp5100_tco # pve2 / pve3
|
||||
cat /sys/class/watchdog/watchdog0/identity # must be the HW one now
|
||||
systemctl start watchdog-mux # Restart=no: start it explicitly
|
||||
ls -l /proc/$(pidof watchdog-mux)/fd | grep watchdog # must hold /dev/watchdog
|
||||
|
||||
# 3. hand the resource back
|
||||
ha-manager set vm:100 --state started
|
||||
ha-manager status
|
||||
```
|
||||
|
||||
If a step fails, leave `vm:100` in `ignored` and fix it — the VM keeps running and
|
||||
nothing gets fenced. `ignored` means HA does not touch the guest, not that it stops.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
ha-manager status # "fencing armed", "service vm:100 (pveN, started)"
|
||||
ha-manager config
|
||||
qm config 100 | grep -E 'agent|onboot'
|
||||
linstor r l | grep pm- # replica placement
|
||||
```
|
||||
|
||||
## Undo
|
||||
|
||||
```bash
|
||||
ha-manager remove vm:100 # disarms fencing once no resources remain
|
||||
rm /etc/modprobe.d/pve-ha-watchdog.conf
|
||||
# and revert WATCHDOG_MODULE in /etc/default/pve-ha-manager
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
[defaults]
|
||||
inventory = inventory/hosts.yml
|
||||
roles_path = roles
|
||||
host_key_checking = False
|
||||
callback_result_format = yaml
|
||||
nocows = True
|
||||
|
||||
# Every host here is a Proxmox node reached as root, so become is unnecessary
|
||||
# (unlike samba-ad/, whose inventory is mixed Linux + Windows).
|
||||
# Encrypted group_vars/all/vault.yml are COMMITTED; the password is not.
|
||||
# Relative to this file, so it resolves for any checkout location.
|
||||
vault_password_file = ../../../.vault_pass
|
||||
|
||||
[ssh_connection]
|
||||
# The homelab LAN and the home uplink are both flaky; keep connections warm and
|
||||
# retry rather than failing a long play halfway through.
|
||||
pipelining = True
|
||||
retries = 3
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# AD realm + user/group sync. Kept separate from site.yml because it writes
|
||||
# cluster-wide config (/etc/pve/domains.cfg) and needs the bind password.
|
||||
#
|
||||
# ansible-playbook auth.yml -e @../../samba-ad/ansible/group_vars/all/vault.yml
|
||||
- name: Proxmox AD authentication realm
|
||||
hosts: pve
|
||||
gather_facts: false
|
||||
roles:
|
||||
- pve_auth
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# Form the "homelab" cluster. SEPARATE from site.yml on purpose: site.yml is
|
||||
# safely re-runnable baseline config, whereas cluster formation is effectively
|
||||
# one-way (undoing a join means reinstalling the node). Run it deliberately.
|
||||
#
|
||||
# ansible-playbook cluster.yml
|
||||
#
|
||||
# serial:1 with the primary first — a joining node needs the primary quorate,
|
||||
# and joining all three at once is how you get a split-brain corosync config.
|
||||
- name: Form the Proxmox cluster
|
||||
hosts: pve
|
||||
gather_facts: true
|
||||
serial: 1
|
||||
order: sorted # pve1 (the primary) first
|
||||
roles:
|
||||
- pve_cluster
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
ansible_user: root
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
|
||||
# Internal domain. NOTE: ad.ddupan.top, NOT lab.ddupan.top -- the installer
|
||||
# answer files originally wrote "lab" and it was corrected post-install on
|
||||
# 2026-07-25. This is the Samba AD realm (see services/samba-ad/).
|
||||
pve_domain: ad.ddupan.top
|
||||
|
||||
# ── apt / repositories ──────────────────────────────────────────────────
|
||||
# Debian codename PVE 9.2 is built on. Derived at runtime, but pinned here as a
|
||||
# fallback so a failed fact-gather cannot silently point apt at the wrong suite.
|
||||
pve_suite: trixie
|
||||
|
||||
# The enterprise repos 401 without a subscription and make every apt run noisy.
|
||||
pve_disable_enterprise_repo: true
|
||||
pve_enable_no_subscription_repo: true
|
||||
|
||||
# Ceph is deliberately NOT used on this cluster -- it was tried on HDD OSDs and
|
||||
# was far too slow. LINSTOR/DRBD replaces it, so the Ceph repo is disabled.
|
||||
pve_disable_ceph_repo: true
|
||||
|
||||
# Strip the "No valid subscription" web-UI dialog.
|
||||
pve_remove_subscription_nag: true
|
||||
|
||||
# Run a full dist-upgrade. Off by default: the home uplink is unstable and a
|
||||
# half-applied upgrade across a live cluster is worse than a stale one. Enable
|
||||
# deliberately: -e pve_dist_upgrade=true
|
||||
pve_dist_upgrade: false
|
||||
|
||||
# ── outbound mail ───────────────────────────────────────────────────────
|
||||
# These nodes cannot hand mail to M365 directly from a residential IP, so all
|
||||
# mail relays through the k3s smtp-relay on the laptop. See services/smtp-relay/.
|
||||
pve_mail_relayhost: "[192.168.10.127]:25"
|
||||
# M365 authenticates as this mailbox and REFUSES to send as anything else
|
||||
# (5.7.60 SendAsDenied), so every local sender is rewritten to it.
|
||||
pve_mail_from: [email protected]
|
||||
|
||||
# ── internal PKI ────────────────────────────────────────────────────────
|
||||
# OpenBao (192.168.10.8) is the homelab root-of-trust. This endpoint is
|
||||
# unauthenticated by design, so nodes can (re)fetch the CA without a token.
|
||||
pve_internal_ca_url: https://bao.ad.ddupan.top:8200/v1/pki/ca/pem
|
||||
|
||||
# ── DNS ─────────────────────────────────────────────────────────────────
|
||||
# See roles/pve_dns for WHY the DC must come first.
|
||||
pve_dns_search: ad.ddupan.top
|
||||
pve_nameservers:
|
||||
- 192.168.10.5 # Samba AD DC: internal zone + external forwarding
|
||||
- 192.168.10.1 # router: external only, NO internal zone
|
||||
pve_dns_probe_name: bao.ad.ddupan.top
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# Proxmox HA. SEPARATE from site.yml deliberately, like cluster.yml: registering an
|
||||
# HA resource arms fencing CLUSTER-WIDE, so a node that loses quorum will self-reboot.
|
||||
# That is a behaviour change for a cluster whose documented posture is "no HA", and it
|
||||
# should be run on purpose rather than as a side effect of a baseline run.
|
||||
#
|
||||
# ansible-playbook ha.yml
|
||||
# ansible-playbook ha.yml --tags watchdog # watchdog config only
|
||||
#
|
||||
# Preconditions (all verified 2026-07-26): cluster quorate, guest disk on replicated
|
||||
# storage (pve-rg / DRBD place-count 2), and `onboot: 1` on the guest.
|
||||
- name: Proxmox HA
|
||||
hosts: pve
|
||||
gather_facts: true
|
||||
roles:
|
||||
- pve_ha
|
||||
@@ -0,0 +1,3 @@
|
||||
# Hardware watchdog for HA fencing. Verified present 2026-07-26 (iTCO_wdt).
|
||||
# softdog cannot fence a frozen kernel; see roles/pve_ha.
|
||||
pve_ha_watchdog_module: "iTCO_wdt"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Hardware watchdog for HA fencing. Verified present 2026-07-26 (sp5100_tco).
|
||||
# softdog cannot fence a frozen kernel; see roles/pve_ha.
|
||||
pve_ha_watchdog_module: "sp5100_tco"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Hardware watchdog for HA fencing. Verified present 2026-07-26 (sp5100_tco).
|
||||
# softdog cannot fence a frozen kernel; see roles/pve_ha.
|
||||
pve_ha_watchdog_module: "sp5100_tco"
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
# 3-node Proxmox VE 9.2 cluster "homelab". All were netboot auto-installed on
|
||||
# 2026-07-25 (see ../pxe/ and the project memory for the PXE recipe).
|
||||
#
|
||||
# IPs are all BELOW the router's DHCP pool (192.168.10.10-250) on purpose.
|
||||
# .5 = samba AD DC (dc1, libvirt VM on the laptop), .8 = reserved for OpenBao,
|
||||
# .127 = laptop ("core" node: NFS + netboot.xyz + k3s + smtp-relay).
|
||||
all:
|
||||
children:
|
||||
pve:
|
||||
hosts:
|
||||
pve1:
|
||||
ansible_host: 192.168.10.4
|
||||
# Intel NUC i3-6100, 16G. Most RAM of the three -> LINSTOR controller.
|
||||
# Its UEFI cannot unpack a large initramfs, so it needs the special
|
||||
# PXE path in ../pxe/ if it is ever rebuilt.
|
||||
pve_install_disk: sdb
|
||||
pve_bulk_disk: sda
|
||||
|
||||
pve2:
|
||||
ansible_host: 192.168.10.7
|
||||
# ThinkCentre, Ryzen 2400GE (Raven Ridge), 8G. THIS is the node that
|
||||
# randomly froze. Kernel workaround below; also set BIOS
|
||||
# "Power Supply Idle Control" -> "Typical Current Idle" (the more
|
||||
# reliable half of the fix, and it can only be done physically).
|
||||
pve_kernel_cmdline_extra: "idle=nomwait processor.max_cstate=1"
|
||||
pve_install_disk: nvme0n1
|
||||
pve_bulk_disk: sda
|
||||
|
||||
pve3:
|
||||
ansible_host: 192.168.10.9
|
||||
# Same Raven Ridge silicon as pve2 but has never frozen. Deliberately
|
||||
# left WITHOUT the C-state workaround: processor.max_cstate=1 blocks
|
||||
# deeper idle states and costs power, so it is not applied pre-emptively.
|
||||
# If pve3 ever freezes, set pve_kernel_cmdline_extra here too.
|
||||
pve_install_disk: nvme0n1
|
||||
pve_bulk_disk: sda
|
||||
|
||||
# Network devices driven over network_cli (NOT ssh/python) — they have no
|
||||
# Python interpreter, so normal modules do not apply.
|
||||
network:
|
||||
hosts:
|
||||
vyos-rtr:
|
||||
ansible_host: 192.168.10.2
|
||||
ansible_user: vyos
|
||||
ansible_connection: ansible.netcommon.network_cli
|
||||
ansible_network_os: vyos.vyos.vyos
|
||||
# Key auth; the vyos password is in ../vyos/credentials.yml (gitignored).
|
||||
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
|
||||
|
||||
# Lab VMs on the cluster (normal ssh/python hosts, unlike the `network` group).
|
||||
labvms:
|
||||
hosts:
|
||||
retrolab:
|
||||
ansible_host: 10.60.0.10
|
||||
ansible_user: panxiao81
|
||||
ansible_become: true
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
# LINSTOR/DRBD storage. Separate from site.yml because it partitions and wipes
|
||||
# disks. The HDD wipe additionally requires -e pve_linstor_wipe_hdd=true.
|
||||
#
|
||||
# ansible-playbook linstor.yml --tags linstor_pkgs # packages only
|
||||
# ansible-playbook linstor.yml -e pve_linstor_wipe_hdd=true
|
||||
- name: LINSTOR hyperconverged storage
|
||||
hosts: pve
|
||||
gather_facts: true
|
||||
roles:
|
||||
- pve_linstor
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# Install with: ansible-galaxy collection install -r requirements.yml
|
||||
#
|
||||
# This file did not exist until ansible-lint flagged unresolvable modules: the
|
||||
# other two projects (samba-ad/, openbao/) declared their collections but this
|
||||
# one relied on whatever happened to be in the operator's environment. A fresh
|
||||
# checkout or a CI runner could not reproduce it.
|
||||
collections:
|
||||
- name: ansible.netcommon # network_cli connection for VyOS — it has no Python
|
||||
# interpreter, so the usual ssh+python path cannot work
|
||||
- name: vyos.vyos # vyos_config; see CLAUDE.md on preferring explicit
|
||||
# `set` lines over the resource modules, which lag upstream
|
||||
- name: ansible.posix # mount, authorized_key
|
||||
- name: community.general # proxmox_kvm (guest lifecycle). NOTE: deb822_repository
|
||||
# is ansible.builtin, NOT community.general — see CLAUDE.md
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# 86Box host: desktop + RDP. AD join is NOT here — that uses the shared
|
||||
# `ad_sssd_join` role in ../../samba-ad/ansible (join-sssd.yml), so every
|
||||
# Linux host joins the domain the same way.
|
||||
- name: Retro lab desktop
|
||||
hosts: retrolab
|
||||
gather_facts: true
|
||||
|
||||
environment:
|
||||
# DO NOT REMOVE. This host holds long-lived interactive desktop sessions.
|
||||
# Ubuntu's needrestart hooks apt and, in non-interactive mode, RESTARTS any
|
||||
# service linked against an upgraded library. On 2026-07-25 an unrelated
|
||||
# `apt install patchelf libcap2-bin` pulled in a libcap2 upgrade, which made
|
||||
# needrestart restart xrdp-sesman. sesman came back with an empty session
|
||||
# table and could no longer reattach the running :10 session, stranding a
|
||||
# live 86Box VM: every reconnect created a NEW session, and xfce4-session
|
||||
# refuses to start twice for one user, so each new session died instantly.
|
||||
# `l` = list what would need restarting, restart nothing.
|
||||
NEEDRESTART_MODE: l
|
||||
|
||||
vars:
|
||||
# ISO library, served from the laptop (the same export PVE mounts as its
|
||||
# `laptop` storage). Mounted READ-ONLY: it is where 86Box picks CD images
|
||||
# from, and nothing on the retro side should ever write to the library.
|
||||
nfs_mounts:
|
||||
- src: "192.168.10.127:/mnt/pool/proxmox/template/iso"
|
||||
path: /mnt/iso
|
||||
|
||||
# The bridge 86Box attaches its TAP interfaces to. net1 (retronet, VLAN
|
||||
# 110) deliberately has no IP on the host — see roles/tap_bridge/defaults.
|
||||
tap_bridge_name: br-retro
|
||||
tap_bridge_member: enp6s19
|
||||
tap_bridge_member_mac: "bc:24:11:9f:63:ed"
|
||||
retro_86box_tap_bridge: br-retro
|
||||
|
||||
roles:
|
||||
- guest_base
|
||||
- retro_desktop
|
||||
- nfs_mounts
|
||||
- tap_bridge
|
||||
- retro_86box
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
# Baseline every lab VM needs to be manageable. Cloud-init installs these on a
|
||||
# FRESH VM; this role covers VMs that already exist (cloud-init's package module
|
||||
# runs once per instance) and repairs any that failed a WAN blip on first boot.
|
||||
|
||||
# The WAN drops mid-transaction often enough that dpkg gets left interrupted
|
||||
# ("dpkg was interrupted, you must manually run 'sudo dpkg --configure -a'"),
|
||||
# after which EVERY later apt run fails. Repair it rather than fail the play.
|
||||
- name: Detect an interrupted dpkg
|
||||
ansible.builtin.command:
|
||||
cmd: dpkg --audit
|
||||
register: _dpkg_audit
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Repair interrupted dpkg
|
||||
ansible.builtin.command:
|
||||
cmd: dpkg --configure -a
|
||||
when: (_dpkg_audit.stdout | default('') | trim) | length > 0
|
||||
changed_when: true
|
||||
|
||||
|
||||
# Repair dpkg if a previous apt run was cut short. NOTE the usual cause is an
|
||||
# operator stopping/rebooting the VM while cloud-init or apt is still running --
|
||||
# not a network fault. Check `cloud-init status` before power-cycling a fresh VM.
|
||||
- name: Detect an interrupted dpkg
|
||||
ansible.builtin.command:
|
||||
cmd: dpkg --audit
|
||||
register: _dpkg_audit
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Repair interrupted dpkg
|
||||
ansible.builtin.command:
|
||||
cmd: dpkg --configure -a
|
||||
when: (_dpkg_audit.stdout | default('') | trim) | length > 0
|
||||
changed_when: true
|
||||
|
||||
- name: Install guest baseline packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
# Gives PVE the guest's IPs, clean shutdown, and fsfreeze for snapshots.
|
||||
# Without it `qm agent <id> ping` fails and PVE is blind inside the guest.
|
||||
- qemu-guest-agent
|
||||
- openssh-server
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
register: _gb
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _gb is succeeded
|
||||
|
||||
- name: Enable the guest agent and sshd
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ item }}"
|
||||
enabled: true
|
||||
state: started
|
||||
loop: [qemu-guest-agent, ssh]
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# Generic NFS client mounts. Shareable: pass a list, get idempotent mounts.
|
||||
#
|
||||
# Each entry: { src, path, opts (optional), state (optional) }
|
||||
nfs_mounts: []
|
||||
|
||||
# Defaults chosen for read-mostly media (ISO libraries), NOT for VM disks:
|
||||
# ro - the share is a library; nothing here should write to it. Also
|
||||
# the only real protection available, because the server exports
|
||||
# it to <world> with no_root_squash.
|
||||
# soft - a hard mount wedges every process that touches the path when the
|
||||
# server or the WAN-adjacent link blips, including the desktop
|
||||
# file manager. Soft returns EIO instead of hanging forever.
|
||||
# _netdev - do not attempt the mount before the network is up.
|
||||
# nofail - a missing NFS server must never stop this host from booting.
|
||||
nfs_mounts_default_opts: "ro,soft,timeo=100,retrans=3,_netdev,nofail,noatime"
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
- name: Install the NFS client
|
||||
ansible.builtin.apt:
|
||||
name: nfs-common
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
register: _nfsc
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _nfsc is succeeded
|
||||
|
||||
- name: Create the mount points
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop: "{{ nfs_mounts }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
- name: Mount the NFS shares
|
||||
ansible.posix.mount:
|
||||
src: "{{ item.src }}"
|
||||
path: "{{ item.path }}"
|
||||
fstype: nfs
|
||||
opts: "{{ item.opts | default(nfs_mounts_default_opts) }}"
|
||||
state: "{{ item.state | default('mounted') }}"
|
||||
loop: "{{ nfs_mounts }}"
|
||||
loop_control:
|
||||
label: "{{ item.src }} -> {{ item.path }}"
|
||||
|
||||
- name: Verify each share is actually readable
|
||||
# `mount` reporting success is not proof: a stale handle or a squashed uid
|
||||
# shows up only on the first read.
|
||||
ansible.builtin.command:
|
||||
cmd: "ls {{ item.path }}"
|
||||
loop: "{{ nfs_mounts }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
register: _nfs_ls
|
||||
changed_when: false
|
||||
when: (item.state | default('mounted')) == 'mounted'
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
# pve_acme role defaults.
|
||||
#
|
||||
# Gives pveproxy (the :8006 web UI) a real certificate from the homelab's own CA,
|
||||
# using PVE's BUILT-IN ACME client rather than a bolted-on lego/certbot. PVE then
|
||||
# owns the whole lifecycle itself: it writes the cert, restarts pveproxy, and
|
||||
# renews daily via pve-daily-update.timer. Nothing extra to install or babysit.
|
||||
|
||||
# --- Which certificate this role touches -------------------------------------
|
||||
# ONLY /etc/pve/local/pveproxy-ssl.pem -- the optional override pveproxy serves.
|
||||
#
|
||||
# It must NEVER be confused with /etc/pve/local/pve-ssl.pem, which is signed by
|
||||
# the per-cluster "PVE Cluster Manager CA" and is what the nodes use to
|
||||
# authenticate each other for the cluster API, live migration and replication.
|
||||
# PVE owns that one and rotates it itself; replacing it breaks the cluster.
|
||||
# When pveproxy-ssl.pem is absent, pveproxy falls back to pve-ssl.pem -- which is
|
||||
# exactly the untrusted-cert warning this role exists to remove.
|
||||
pve_acme_cert_file: /etc/pve/local/pveproxy-ssl.pem
|
||||
|
||||
# --- CA / directory ----------------------------------------------------------
|
||||
# OpenBao's internal PKI, via the ROLE-SCOPED ACME directory (same endpoint shape
|
||||
# as samba-ad's samba_ad_acme role). The role scope matters for security: the
|
||||
# unscoped /v1/pki/acme/directory would fall back to whatever
|
||||
# default_directory_policy is set to, whereas this URL pins issuance to
|
||||
# bao-server, whose allowed_domains caps it at subdomains of ad.ddupan.top.
|
||||
#
|
||||
# WHY the internal CA and not Let's Encrypt (decided 2026-07-26):
|
||||
# * renewal must not depend on the WAN -- the uplink drops at random and the
|
||||
# hypervisor management plane is the last thing that should need the internet
|
||||
# * per-node names pve1/2/3.ad.ddupan.top would otherwise be published to public
|
||||
# Certificate Transparency logs, which is precisely what the wildcard
|
||||
# convention in services/cert-manager/ exists to avoid
|
||||
# * it keeps the Cloudflare DNS token off all three hypervisors
|
||||
# COST: browsers must trust "ddupan.top Internal CA". The nodes themselves already
|
||||
# do (role pve_ca_trust); your workstation needs it installed once.
|
||||
pve_acme_directory: "https://bao.ad.ddupan.top:8200/v1/pki/roles/bao-server/acme/directory"
|
||||
|
||||
# ACME account name. Lives in /etc/pve/priv/acme/<name>, which is on the pmxcfs --
|
||||
# so it is CLUSTER-WIDE and only ever registered once, not once per node.
|
||||
pve_acme_account: default
|
||||
|
||||
# OpenBao ignores the contact address, but the ACME protocol requires one.
|
||||
pve_acme_email: [email protected]
|
||||
|
||||
# --- Challenge ---------------------------------------------------------------
|
||||
# "standalone" is PVE's built-in http-01 plugin: it starts a throwaway listener on
|
||||
# port 80 for the duration of the challenge. That works here -- and would NOT work
|
||||
# against Let's Encrypt -- because bao is on the same flat LAN, resolves
|
||||
# ad.ddupan.top via the DC, and fetches the challenge directly. Nothing is exposed
|
||||
# to the internet and no port forward is involved.
|
||||
#
|
||||
# Requires port 80 to be free on the node. Verified 2026-07-26: PVE listens on
|
||||
# 8006/3128/111 but nothing on 80, and the PVE firewall is disabled cluster-wide.
|
||||
pve_acme_plugin: standalone
|
||||
|
||||
# The name to certify. Per-node, unlike the account and plugin config.
|
||||
pve_acme_domain: "{{ inventory_hostname }}.{{ pve_domain }}"
|
||||
|
||||
# The CA's subject, used to decide whether an existing pveproxy-ssl.pem already
|
||||
# came from us or is a leftover that should be replaced.
|
||||
pve_acme_issuer_cn: "ddupan.top Internal CA"
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
# Point PVE's built-in ACME client at OpenBao's internal PKI and get a real cert
|
||||
# onto pveproxy. See defaults/main.yml for WHY the internal CA over Let's Encrypt.
|
||||
#
|
||||
# Everything here is idempotent: a second run reports changed=0. Renewal is NOT
|
||||
# our job -- pve-daily-update.timer runs `pvenode acme cert renew` once a day and
|
||||
# PVE reissues when the cert is inside 30 days of expiry.
|
||||
|
||||
# --- Preconditions -----------------------------------------------------------
|
||||
# The standalone plugin binds :80 for the challenge. If something else holds it,
|
||||
# the order fails deep inside pvenode with a confusing error, so check up front.
|
||||
- name: Check that port 80 is free for the http-01 challenge
|
||||
ansible.builtin.command: ss -lnt 'sport = :80'
|
||||
register: _port80
|
||||
changed_when: false
|
||||
# Read-only, and every later condition depends on it -- so it must still run
|
||||
# under --check, or the whole role errors out on an undefined register.
|
||||
check_mode: false
|
||||
|
||||
- name: Fail early if port 80 is occupied
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "':80' not in _port80.stdout"
|
||||
fail_msg: >-
|
||||
Something is already listening on port 80; the ACME standalone plugin cannot
|
||||
bind it and the order would fail. Free the port or switch to a dns-01 plugin.
|
||||
quiet: true
|
||||
|
||||
# --- ACME account (cluster-wide, registered once) ----------------------------
|
||||
# /etc/pve/priv/acme/ is on the pmxcfs, so the account is shared by all three
|
||||
# nodes. Guarding on the file -- rather than run_once -- is deliberate: this play
|
||||
# uses serial: 1, where each host is its own batch and run_once would therefore
|
||||
# fire on EVERY host, re-registering the account three times.
|
||||
- name: Check whether the ACME account already exists
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/pve/priv/acme/{{ pve_acme_account }}"
|
||||
register: _acme_account
|
||||
|
||||
- name: Register the ACME account against OpenBao
|
||||
# Non-interactive only because bao's directory advertises no termsOfService
|
||||
# (verified 2026-07-26: meta contains just externalAccountRequired=false).
|
||||
# pvenode prompts for ToS acceptance when a CA does publish one, and there is
|
||||
# no --accept-tos flag to suppress it -- so a CA change here can hang the play.
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pvenode
|
||||
- acme
|
||||
- account
|
||||
- register
|
||||
- "{{ pve_acme_account }}"
|
||||
- "{{ pve_acme_email }}"
|
||||
- --directory
|
||||
- "{{ pve_acme_directory }}"
|
||||
when: not _acme_account.stat.exists
|
||||
changed_when: true
|
||||
|
||||
# --- Per-node domain config --------------------------------------------------
|
||||
# Written to /etc/pve/nodes/<node>/config as an `acmedomain0:` line. That file
|
||||
# does not exist until the first `pvenode config set`, hence the default('').
|
||||
- name: Read the node config
|
||||
ansible.builtin.slurp:
|
||||
src: "/etc/pve/nodes/{{ inventory_hostname }}/config"
|
||||
register: _node_cfg
|
||||
failed_when: false
|
||||
|
||||
- name: Configure the ACME domain for this node
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pvenode
|
||||
- config
|
||||
- set
|
||||
- "--acmedomain0"
|
||||
- "{{ pve_acme_domain }},plugin={{ pve_acme_plugin }}"
|
||||
vars:
|
||||
_want: "acmedomain0: {{ pve_acme_domain }},plugin={{ pve_acme_plugin }}"
|
||||
when: _want not in (_node_cfg.content | default('') | b64decode)
|
||||
changed_when: true
|
||||
|
||||
# --- Certificate -------------------------------------------------------------
|
||||
# Order only when there is no usable cert already. Checking the ISSUER rather
|
||||
# than mere existence means a leftover self-signed or previously-Let's-Encrypted
|
||||
# pveproxy-ssl.pem gets replaced, while our own cert is left alone for PVE's
|
||||
# renewal timer to manage.
|
||||
- name: Inspect the current pveproxy certificate
|
||||
ansible.builtin.command: "openssl x509 -noout -issuer -subject -in {{ pve_acme_cert_file }}"
|
||||
register: _current_cert
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false # read-only; the order task's condition depends on it
|
||||
|
||||
- name: Order the certificate from OpenBao
|
||||
# --force overwrites an existing pveproxy-ssl.pem, which is what we want once
|
||||
# the check above has decided the current one is wrong or missing. It does NOT
|
||||
# touch pve-ssl.pem, so the cluster's internal trust is unaffected.
|
||||
#
|
||||
# KNOWN FAILURE MODE: the bao-server PKI role pins key_type=rsa/key_bits=2048.
|
||||
# If a future PVE generates an EC CSR, finalize is rejected by OpenBao with a
|
||||
# key-type error (the same trap documented in samba-ad's samba_ad_acme role).
|
||||
# The fix is on the bao side -- widen the role -- not here.
|
||||
ansible.builtin.command: pvenode acme cert order --force
|
||||
when: >-
|
||||
_current_cert.rc != 0
|
||||
or pve_acme_issuer_cn not in _current_cert.stdout
|
||||
or pve_acme_domain not in _current_cert.stdout
|
||||
changed_when: true
|
||||
|
||||
# --- Renewal -----------------------------------------------------------------
|
||||
# This timer is what keeps the cert alive; without it the cert simply expires in
|
||||
# place. Enabled by default on PVE, asserted here so the guarantee is explicit
|
||||
# rather than assumed.
|
||||
- name: Ensure the daily renewal timer is enabled
|
||||
ansible.builtin.systemd:
|
||||
name: pve-daily-update.timer
|
||||
state: started
|
||||
enabled: true
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
# Authenticate PVE users against Samba AD over verified LDAPS.
|
||||
#
|
||||
# WHY the AD realm and not OIDC: PVE's `ad`/`ldap` realms support a real user +
|
||||
# group SYNC (pveum realm sync), so permissions can be granted to a group BEFORE
|
||||
# anyone logs in, and username+password works for the API/CLI (Terraform,
|
||||
# pvesh). PVE's OIDC realm only creates users on first browser login and cannot
|
||||
# do non-interactive auth. Authelia OIDC can be added later as an EXTRA realm
|
||||
# for convenient SSO; it is not a replacement.
|
||||
|
||||
pve_auth_realm: ad # users appear as <user>@ad
|
||||
pve_auth_domain: ad.ddupan.top
|
||||
|
||||
# MUST be the hostname, NEVER 192.168.10.5: the DC's LDAPS cert is issued by
|
||||
# OpenBao ACME with a DNS SAN only (no IP SAN), so verification fails by IP with
|
||||
# "IP address mismatch". Verified 2026-07-25.
|
||||
pve_auth_server1: dc1.ad.ddupan.top
|
||||
pve_auth_port: 636
|
||||
pve_auth_mode: ldaps # `--secure` is DEPRECATED in favour of this
|
||||
|
||||
# The whole point of trusting the internal CA on these nodes (role pve_ca_trust):
|
||||
# PVE defaults --verify to 0, i.e. it does NOT check the DC's certificate, which
|
||||
# makes the directory bind trivially MITM-able on a flat LAN. capath's default
|
||||
# (/etc/ssl/certs) already contains the hashed internal CA.
|
||||
pve_auth_verify: 1
|
||||
pve_auth_capath: /etc/ssl/certs
|
||||
|
||||
pve_auth_base_dn: "DC=ad,DC=ddupan,DC=top"
|
||||
pve_auth_bind_dn: "CN=svc-pve,CN=Users,DC=ad,DC=ddupan,DC=top"
|
||||
# Read-only bind account created by samba-ad (samba_ad_service_accounts).
|
||||
pve_auth_bind_password: "{{ vault_pve_bind_password | default('') }}"
|
||||
|
||||
# AD logon name. Without this PVE would try the default LDAP `uid`, which AD
|
||||
# does not populate.
|
||||
pve_auth_user_attr: sAMAccountName
|
||||
# AD stores users AND groups under CN=Users by default (not an OU).
|
||||
pve_auth_group_dn: "CN=Users,DC=ad,DC=ddupan,DC=top"
|
||||
pve_auth_group_classes: group
|
||||
pve_auth_user_classes: user
|
||||
|
||||
# Only sync real, ENABLED people — not service or system accounts.
|
||||
# The userAccountControl bit-AND rule (1.2.840.113556.1.4.803 := 2) excludes
|
||||
# DISABLED accounts, which is what keeps AD's built-in `Guest` and `krbtgt` out.
|
||||
# Filtering on name alone let both through on the first sync.
|
||||
pve_auth_filter: >-
|
||||
(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(sAMAccountName=svc-*)))
|
||||
|
||||
# Only groups this cluster actually uses for RBAC. `(objectClass=group)` pulls in
|
||||
# every builtin AD group: PVE rejects most outright ("group name 'Domain Users-ad'
|
||||
# contains invalid characters" — spaces are illegal in PVE group IDs) and imports
|
||||
# the rest as clutter (DnsAdmins-ad, DnsUpdateProxy-ad). Convention: name any
|
||||
# group PVE should see `pve-*`.
|
||||
pve_auth_group_filter: "(&(objectClass=group)(cn=pve-*))"
|
||||
pve_auth_sync_attributes: "email=mail,firstname=givenName,lastname=sn"
|
||||
|
||||
# AD logon names are case-insensitive; PVE defaults to case-sensitive, which
|
||||
# means Panxiao81 and panxiao81 would become two different PVE users.
|
||||
pve_auth_case_sensitive: 0
|
||||
|
||||
# remove-vanished: 'none' on purpose. Anything stronger lets a transient LDAP
|
||||
# hiccup delete users, their properties, or their ACLs from a live cluster.
|
||||
pve_auth_sync_defaults: "scope=both,enable-new=1,remove-vanished=none"
|
||||
|
||||
# --- scheduled sync ---
|
||||
pve_auth_sync_job: pve-ad-sync
|
||||
pve_auth_sync_schedule: "*-*-* 04:11:00"
|
||||
|
||||
# --- RBAC ---
|
||||
# PVE RENAMES synced groups to "<name>-<realm>", so AD's pve-admins becomes
|
||||
# pve-admins-ad. Granting the ACL to "pve-admins" would silently match nothing.
|
||||
pve_auth_admin_group: "pve-admins-{{ pve_auth_realm }}"
|
||||
pve_auth_admin_role: Administrator
|
||||
pve_auth_admin_path: /
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
# Realm config lives in /etc/pve/domains.cfg, which is REPLICATED cluster-wide,
|
||||
# so every task here runs once against a single node. Doing it per-host would
|
||||
# just have three nodes racing to write the same file.
|
||||
|
||||
- name: Require the bind password
|
||||
ansible.builtin.assert:
|
||||
that: pve_auth_bind_password | length > 0
|
||||
fail_msg: >-
|
||||
pve_auth_bind_password is empty. It comes from vault_pve_bind_password in
|
||||
samba-ad/ansible/group_vars/all/vault.yml — pass it via -e or a vars file.
|
||||
quiet: true
|
||||
run_once: true
|
||||
|
||||
- name: Check whether the realm already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm list --output-format json"
|
||||
register: _realms
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Decide create vs update
|
||||
ansible.builtin.set_fact:
|
||||
_realm_exists: "{{ pve_auth_realm in (_realms.stdout | from_json | map(attribute='realm') | list) }}"
|
||||
run_once: true
|
||||
|
||||
# --- shared option set, so create and update cannot drift apart -------------
|
||||
# `--type` is deliberately NOT in here: a realm's type is immutable, and
|
||||
# `pveum realm modify --type ad` fails with "Unknown option: type". It is passed
|
||||
# only on the create path below.
|
||||
- name: Build the realm option string
|
||||
ansible.builtin.set_fact:
|
||||
_realm_opts: >-
|
||||
--domain {{ pve_auth_domain }}
|
||||
--server1 {{ pve_auth_server1 }}
|
||||
--port {{ pve_auth_port }}
|
||||
--mode {{ pve_auth_mode }}
|
||||
--verify {{ pve_auth_verify }}
|
||||
--capath {{ pve_auth_capath }}
|
||||
--base_dn '{{ pve_auth_base_dn }}'
|
||||
--bind_dn '{{ pve_auth_bind_dn }}'
|
||||
--user_attr {{ pve_auth_user_attr }}
|
||||
--user_classes '{{ pve_auth_user_classes }}'
|
||||
--group_dn '{{ pve_auth_group_dn }}'
|
||||
--group_classes '{{ pve_auth_group_classes }}'
|
||||
--filter '{{ pve_auth_filter }}'
|
||||
--group_filter '{{ pve_auth_group_filter }}'
|
||||
--sync_attributes '{{ pve_auth_sync_attributes }}'
|
||||
--sync-defaults-options '{{ pve_auth_sync_defaults }}'
|
||||
--case-sensitive {{ pve_auth_case_sensitive }}
|
||||
--comment 'Samba AD (dc1) over verified LDAPS'
|
||||
run_once: true
|
||||
|
||||
- name: Create the realm
|
||||
# --check-connection makes PVE actually bind before saving, so a wrong DN,
|
||||
# password, or an untrusted certificate fails HERE instead of silently
|
||||
# producing a realm nobody can log in to.
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
pveum realm add {{ pve_auth_realm }} --type ad {{ _realm_opts }}
|
||||
--password '{{ pve_auth_bind_password }}'
|
||||
--check-connection 1
|
||||
when: not _realm_exists
|
||||
run_once: true
|
||||
# no_log hides the bind password, but it also hides WHY a failure happened.
|
||||
# Keep it on (the password is on the command line) and rely on
|
||||
# --check-connection plus the manual `pveum realm modify` path for diagnosis.
|
||||
no_log: true
|
||||
|
||||
- name: Read the current realm config
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh get /access/domains/{{ pve_auth_realm }} --output-format json"
|
||||
register: _realm_cur
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: _realm_exists
|
||||
run_once: true
|
||||
|
||||
- name: Detect realm drift
|
||||
# pveum has no diff mode, so compare the fields we manage. Without this the
|
||||
# role reported "changed" on every single run, which makes real drift invisible.
|
||||
ansible.builtin.set_fact:
|
||||
_realm_drift: "{{ _realm_exists and (
|
||||
(_realm_cur.stdout | from_json).get('server1') != pve_auth_server1 or
|
||||
(_realm_cur.stdout | from_json).get('base_dn') != pve_auth_base_dn or
|
||||
(_realm_cur.stdout | from_json).get('bind_dn') != pve_auth_bind_dn or
|
||||
(_realm_cur.stdout | from_json).get('mode') != pve_auth_mode or
|
||||
(_realm_cur.stdout | from_json).get('verify') | default(0) | int != pve_auth_verify | int or
|
||||
(_realm_cur.stdout | from_json).get('filter') != pve_auth_filter | trim or
|
||||
(_realm_cur.stdout | from_json).get('group_filter') != pve_auth_group_filter or
|
||||
(_realm_cur.stdout | from_json).get('user_attr') != pve_auth_user_attr or
|
||||
(_realm_cur.stdout | from_json).get('sync_attributes') != pve_auth_sync_attributes
|
||||
) }}"
|
||||
run_once: true
|
||||
|
||||
- name: Update the realm
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
pveum realm modify {{ pve_auth_realm }} {{ _realm_opts }}
|
||||
--password '{{ pve_auth_bind_password }}'
|
||||
--check-connection 1
|
||||
when: _realm_drift | default(false)
|
||||
run_once: true
|
||||
no_log: true
|
||||
|
||||
- name: Show the resulting realm
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm list --output-format json"
|
||||
register: _realm_after
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Report it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _realm_after.stdout | from_json | selectattr('realm', 'equalto', pve_auth_realm) | list }}"
|
||||
run_once: true
|
||||
|
||||
# --- initial sync ----------------------------------------------------------
|
||||
- name: Dry-run the sync first
|
||||
# Proves the bind and filters work, and shows what WOULD be imported, without
|
||||
# writing to user.cfg.
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm sync {{ pve_auth_realm }} --scope both --dry-run 1"
|
||||
register: _sync_dry
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Show what the sync would import
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _sync_dry.stderr_lines | default([]) + _sync_dry.stdout_lines | default([]) }}"
|
||||
run_once: true
|
||||
|
||||
- name: Sync users and groups
|
||||
# Always reports "changed": this is a reconcile ACTION against a live
|
||||
# directory, not a declared state, and pveum gives no no-op signal to key off.
|
||||
# The realm config and ACL above ARE drift-detected, so a re-run showing
|
||||
# changed=1 means "sync ran", not "something was wrong".
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm sync {{ pve_auth_realm }} --scope both --enable-new 1 --remove-vanished none"
|
||||
register: _sync
|
||||
changed_when: true
|
||||
run_once: true
|
||||
|
||||
# --- scheduled sync --------------------------------------------------------
|
||||
- name: List existing realm-sync jobs
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh get /cluster/jobs/realm-sync --output-format json"
|
||||
register: _jobs
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Create the scheduled sync job
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesh create /cluster/jobs/realm-sync/{{ pve_auth_sync_job }}
|
||||
--realm {{ pve_auth_realm }}
|
||||
--schedule '{{ pve_auth_sync_schedule }}'
|
||||
--scope both --enable-new 1 --remove-vanished none --enabled 1
|
||||
--comment 'Nightly AD user/group sync'
|
||||
when: pve_auth_sync_job not in (_jobs.stdout | from_json | map(attribute='id') | list)
|
||||
run_once: true
|
||||
|
||||
# --- RBAC ------------------------------------------------------------------
|
||||
- name: Read current ACLs
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum acl list --output-format json"
|
||||
register: _acl_cur
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Grant the admin group its role
|
||||
# NOTE the group name: PVE appends "-<realm>" to every synced group, so AD's
|
||||
# `pve-admins` is `pve-admins-ad` here. Granting to the AD name matches nothing.
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pveum acl modify {{ pve_auth_admin_path }}
|
||||
--group {{ pve_auth_admin_group }} --role {{ pve_auth_admin_role }}
|
||||
when: >-
|
||||
(_acl_cur.stdout | from_json
|
||||
| selectattr('path', 'equalto', pve_auth_admin_path)
|
||||
| selectattr('ugid', 'equalto', pve_auth_admin_group)
|
||||
| selectattr('roleid', 'equalto', pve_auth_admin_role) | list | length) == 0
|
||||
run_once: true
|
||||
|
||||
- name: Report users, groups and ACLs
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
echo "--- users ---"; pveum user list --output-format json | python3 -c "import json,sys;[print(' ',u['userid']) for u in json.load(sys.stdin)]"
|
||||
echo "--- groups ---"; pveum group list --output-format json | python3 -c "import json,sys;[print(' ',g['groupid'], g.get('users','')) for g in json.load(sys.stdin)]"
|
||||
echo "--- acls ---"; pveum acl list --output-format json | python3 -c "import json,sys;[print(' ',a['path'],a.get('ugid'),a.get('roleid')) for a in json.load(sys.stdin)]"
|
||||
register: _final
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Show it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _final.stdout_lines }}"
|
||||
run_once: true
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Update CA certificates
|
||||
ansible.builtin.command:
|
||||
cmd: update-ca-certificates
|
||||
changed_when: true
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
# Trust the homelab's internal CA (OpenBao pki/, "ddupan.top Internal CA").
|
||||
#
|
||||
# Needed so the PVE nodes can verify LDAPS against the Samba AD DC with
|
||||
# verify=1 instead of disabling verification -- an unverified directory bind is
|
||||
# trivially MITM-able on a flat LAN, and the whole point of running our own CA
|
||||
# is not having to do that.
|
||||
#
|
||||
# The CA is pulled from bao's UNAUTHENTICATED /v1/pki/ca/pem endpoint (same
|
||||
# approach as roles/openbao_ssh_ca_trust in services/openbao): no token needed,
|
||||
# and a CA rotation is picked up simply by re-running this.
|
||||
|
||||
- name: Fetch the internal CA from OpenBao
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pve_internal_ca_url }}"
|
||||
return_content: true
|
||||
# bao serves a real Let's Encrypt cert (openbao_acme role), so normal
|
||||
# verification works here -- do NOT relax this.
|
||||
validate_certs: true
|
||||
register: _bao_ca
|
||||
changed_when: false
|
||||
retries: 3
|
||||
delay: 10
|
||||
until: _bao_ca is succeeded
|
||||
|
||||
- name: Sanity-check that we actually got a CA certificate
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'BEGIN CERTIFICATE' in _bao_ca.content"
|
||||
fail_msg: "OpenBao did not return a PEM certificate -- refusing to install it as a trust anchor."
|
||||
quiet: true
|
||||
|
||||
- name: Install the internal CA into the system trust store
|
||||
ansible.builtin.copy:
|
||||
dest: /usr/local/share/ca-certificates/ddupan-internal-ca.crt
|
||||
content: "{{ _bao_ca.content }}"
|
||||
mode: "0644"
|
||||
notify: Update CA certificates
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
pve_cluster_name: homelab
|
||||
|
||||
# The node that runs `pvecm create`; everyone else joins it. pve1 is also the
|
||||
# intended LINSTOR controller (most RAM of the three).
|
||||
pve_cluster_primary: pve1
|
||||
|
||||
# Corosync link address per node. Single flat 1G LAN on one unmanaged switch, so
|
||||
# there is exactly one ring. A dedicated corosync ring would need a second NIC
|
||||
# (mini-PCIe) plus another switch — noted as a future upgrade, not done here.
|
||||
pve_cluster_link0: "{{ ansible_host }}"
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
# Form the PVE cluster. THIS IS THE ONE STEP IN THIS REPO THAT IS NOT EASILY
|
||||
# REVERSIBLE: undoing a join effectively means reinstalling the node. Everything
|
||||
# guards on "already clustered" so re-runs are safe.
|
||||
|
||||
- name: Detect existing cluster membership
|
||||
ansible.builtin.stat:
|
||||
path: /etc/pve/corosync.conf
|
||||
register: _corosync
|
||||
changed_when: false
|
||||
|
||||
- name: Record membership
|
||||
ansible.builtin.set_fact:
|
||||
_in_cluster: "{{ _corosync.stat.exists }}"
|
||||
|
||||
# ── SSH trust, needed for a non-interactive `pvecm add` ───────────────────
|
||||
# `pvecm add --use_ssh` authenticates over SSH from the JOINING node to the
|
||||
# primary. Without pre-shared keys it prompts for the root password, which
|
||||
# cannot be automated cleanly.
|
||||
- name: Ensure root has an SSH keypair
|
||||
ansible.builtin.user:
|
||||
name: root
|
||||
generate_ssh_key: true
|
||||
ssh_key_type: ed25519
|
||||
ssh_key_file: .ssh/id_ed25519
|
||||
when: not _in_cluster
|
||||
check_mode: false # --check would otherwise leave the next task nothing to read
|
||||
|
||||
- name: Read this node's root public key
|
||||
ansible.builtin.slurp:
|
||||
src: /root/.ssh/id_ed25519.pub
|
||||
register: _rootpub
|
||||
when: not _in_cluster
|
||||
check_mode: false
|
||||
|
||||
- name: Authorise THIS node's root key on the primary
|
||||
# Each joining node pushes its OWN key. Do NOT loop over groups['pve'] reading
|
||||
# hostvars[item]._rootpub: with serial:1 the later nodes have not run yet, so
|
||||
# their facts are undefined and their keys would silently never be installed —
|
||||
# `pvecm add --use_ssh` would then sit waiting for a password.
|
||||
ansible.posix.authorized_key:
|
||||
user: root
|
||||
key: "{{ _rootpub.content | b64decode }}"
|
||||
state: present
|
||||
delegate_to: "{{ pve_cluster_primary }}"
|
||||
when:
|
||||
- not _in_cluster
|
||||
- inventory_hostname != pve_cluster_primary
|
||||
|
||||
- name: Pre-seed the primary's host key so SSH does not prompt
|
||||
ansible.builtin.known_hosts:
|
||||
path: /root/.ssh/known_hosts
|
||||
name: "{{ hostvars[pve_cluster_primary].ansible_host }}"
|
||||
key: "{{ lookup('pipe', 'ssh-keyscan -t ed25519 ' + hostvars[pve_cluster_primary].ansible_host + ' 2>/dev/null') }}"
|
||||
state: present
|
||||
when:
|
||||
- not _in_cluster
|
||||
- inventory_hostname != pve_cluster_primary
|
||||
|
||||
# ── create / join ─────────────────────────────────────────────────────────
|
||||
- name: Create the cluster on the primary
|
||||
ansible.builtin.command:
|
||||
cmd: "pvecm create {{ pve_cluster_name }} --link0 {{ pve_cluster_link0 }}"
|
||||
when:
|
||||
- not _in_cluster
|
||||
- inventory_hostname == pve_cluster_primary
|
||||
|
||||
- name: Wait for the primary to report quorum before anyone joins
|
||||
ansible.builtin.command:
|
||||
cmd: pvecm status
|
||||
register: _primary_q
|
||||
until: _primary_q.stdout is search('Quorate:\s+Yes')
|
||||
retries: 12
|
||||
delay: 5
|
||||
changed_when: false
|
||||
# No cluster exists during --check (the create is skipped), so there is nothing
|
||||
# to wait for; skip rather than fail the dry run.
|
||||
when:
|
||||
- inventory_hostname == pve_cluster_primary
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Join the cluster
|
||||
# --use_ssh avoids the interactive API-ticket password prompt.
|
||||
ansible.builtin.command:
|
||||
cmd: "pvecm add {{ hostvars[pve_cluster_primary].ansible_host }} --link0 {{ pve_cluster_link0 }} --use_ssh"
|
||||
when:
|
||||
- not _in_cluster
|
||||
- inventory_hostname != pve_cluster_primary
|
||||
|
||||
- name: Wait for this node to be quorate
|
||||
ansible.builtin.command:
|
||||
cmd: pvecm status
|
||||
register: _q
|
||||
until: _q.stdout is search('Quorate:\s+Yes')
|
||||
retries: 24
|
||||
delay: 5
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
# Resolver configuration.
|
||||
#
|
||||
# ORDER MATTERS AND IS NOT ARBITRARY:
|
||||
# .5 = Samba AD DC (dc1) -- AUTHORITATIVE for ad.ddupan.top AND forwards
|
||||
# external queries onward. Resolves both internal and public names.
|
||||
# .1 = LAN router -- resolves public names ONLY. Internal ad.ddupan.top
|
||||
# lookups return EMPTY here (verified 2026-07-25: `dig @192.168.10.1
|
||||
# bao.ad.ddupan.top` -> nothing, while @192.168.10.5 -> 192.168.10.8).
|
||||
#
|
||||
# The installer left these nodes pointing at .1 alone, which silently broke
|
||||
# every internal name -- including the OpenBao CA fetch and, later, the AD
|
||||
# realm. .1 is kept as a SECOND entry purely so public DNS survives the DC
|
||||
# being down (dc1 is a VM on the laptop); internal names correctly fail then.
|
||||
|
||||
- name: Configure resolv.conf
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/resolv.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by Ansible (services/proxmox/ansible, role pve_dns).
|
||||
search {{ pve_dns_search }}
|
||||
{% for ns in pve_nameservers %}
|
||||
nameserver {{ ns }}
|
||||
{% endfor %}
|
||||
|
||||
- name: Verify an internal name now resolves
|
||||
# Guards against a regression that would otherwise only surface much later as
|
||||
# a confusing failure in an unrelated role.
|
||||
ansible.builtin.command:
|
||||
cmd: getent hosts {{ pve_dns_probe_name }}
|
||||
register: _dns_probe
|
||||
changed_when: false
|
||||
failed_when: _dns_probe.rc != 0
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: _dns_probe is succeeded
|
||||
|
||||
# ── address-family preference ─────────────────────────────────────────────
|
||||
# These nodes are IPv4-ONLY: no global IPv6 address, no default IPv6 route
|
||||
# (verified 2026-07-25). But the DC returns AAAA records and glibc hands those
|
||||
# out first, so anything resolving a dual-stack name tries a dead IPv6 path.
|
||||
# That is exactly how `apt update` against packages.linbit.com stalled: it
|
||||
# resolved to 2a01:4f8:1c1c:6ab9::1 and hung, while IPv4 answered fine.
|
||||
#
|
||||
# Prefer IPv4 system-wide until this LAN actually has IPv6 egress.
|
||||
- name: Prefer IPv4 over IPv6 in glibc resolution
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/gai.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by Ansible (services/proxmox/ansible, role pve_dns).
|
||||
# Raise the precedence of IPv4-mapped addresses above native IPv6 so
|
||||
# getaddrinfo() returns A records first on this IPv4-only network.
|
||||
precedence ::ffff:0:0/96 100
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
# Web UI for the floppy drives PVE cannot express (see files/floppy-ui.py).
|
||||
#
|
||||
# ONE instance serves the cluster: every action goes through `pvesh`, which
|
||||
# proxies to whichever node owns the VM, so this does not need to run on the
|
||||
# node the guest happens to live on -- and keeps working after a migration.
|
||||
|
||||
#
|
||||
# LOGIN is PVE's own: the app posts credentials to /access/ticket, so it accepts
|
||||
# whatever realms the cluster has -- `pam` for node-local accounts and `ad` for
|
||||
# Samba AD over verified LDAPS (role pve_auth) -- and stores no bind credential.
|
||||
# A session additionally needs Sys.Modify on / in PVE's ACL, which in practice
|
||||
# means the pve-admins-ad group. It serves TLS with the node's own ACME cert, so
|
||||
# reach it BY HOSTNAME: https://pve1.ad.ddupan.top:8088 (CN/SAN is the FQDN, and
|
||||
# there is no IP SAN -- the same trap as the DC's LDAPS cert).
|
||||
|
||||
pve_floppy_listen: "0.0.0.0"
|
||||
pve_floppy_port: 8088
|
||||
# The shared ISO storage (`laptop`), mounted at the same path on every node.
|
||||
pve_floppy_image_dir: /mnt/pve/laptop/template/iso
|
||||
|
||||
# The retro modem switchboard's phonebook (role retro_modem). It lives on
|
||||
# pmxcfs so this UI, running on pve1, edits the same file the switchboard reads
|
||||
# on pve3 -- no API and no IPC between them: the file IS the interface, and the
|
||||
# switchboard re-reads it on the next dial.
|
||||
pve_floppy_phonebook: /etc/pve/retro-phonebook
|
||||
@@ -0,0 +1,711 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Floppy drives for Proxmox VMs, which PVE itself cannot express.
|
||||
|
||||
There is no floppy in the PVE UI *or* the VM config schema, and PVE starts QEMU
|
||||
with -nodefaults, so a retro guest has no A: at all unless one is smuggled in
|
||||
through the `args` field. This is the UI for that.
|
||||
|
||||
Two facts shape the whole design:
|
||||
|
||||
* `args` is root@pam-ONLY. The check in PVE::API2::Qemu is a literal
|
||||
`$authuser eq 'root@pam'`, and an API token's authuser is `root@pam!name`,
|
||||
so no token can ever set it. Hence: run on a node, shell out to `pvesh` as
|
||||
root. A network API client would need the root PASSWORD, which is worse.
|
||||
* `pvesh` proxies to whichever node owns the VM. So ONE instance on ONE node
|
||||
serves the whole cluster and keeps working when a VM migrates.
|
||||
|
||||
Changing `args` only takes effect on the next QEMU process, i.e. a real stop +
|
||||
start; a guest-initiated reboot reuses the same process. Swapping the *medium*
|
||||
of an existing drive is live, via the monitor. The UI reflects that split, and
|
||||
deliberately has no stop/start buttons -- the PVE UI already has those.
|
||||
|
||||
LOGIN: no PAM code and no LDAP code here. The node already authenticates against
|
||||
both -- realm `pam` for local accounts and realm `ad` for Samba AD over VERIFIED
|
||||
LDAPS (roles/pve_auth) -- so this posts the credentials to PVE's own
|
||||
/access/ticket and believes the answer. That also means it inherits the realm
|
||||
list, the LDAPS certificate verification, and the account lockouts for free, and
|
||||
holds no bind DN or password of its own.
|
||||
|
||||
Then AUTHORISATION, which is the half that matters: authenticating merely proves
|
||||
you are someone in AD. Inserting a host file into a VM is a host-level action, so
|
||||
a session additionally needs Sys.Modify on `/` in PVE's own ACL -- i.e. the same
|
||||
`pve-admins-ad` group that already administers the cluster.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# Floppy media. .vfd is what the NT4 driver disks ship as; .ima/.flp/.dsk show
|
||||
# up in retro archives. .iso is deliberately absent -- CD-ROMs PVE handles.
|
||||
SUFFIXES = (".img", ".ima", ".vfd", ".flp", ".dsk")
|
||||
|
||||
# `args` is one shell-ish string and HMP `change` takes an unquoted path, so a
|
||||
# filename with whitespace cannot be expressed in either without quoting rules
|
||||
# that differ between the two. Such images are hidden rather than half-supported.
|
||||
# ponytail: if a spaced filename ever matters, shlex.quote for args + rename for HMP.
|
||||
FLOPPY_ARG = re.compile(r"-drive\s+if=floppy[^\s]*")
|
||||
|
||||
# pvedaemon, the API pveproxy itself proxies to. Used instead of `pvesh create
|
||||
# /access/ticket` so the password never appears in a process argv, and instead of
|
||||
# pveproxy:8006 so there is no TLS-to-self certificate dance. It is bound to
|
||||
# loopback by PVE, so plain HTTP here does not put anything on the wire.
|
||||
PVEDAEMON = "http://127.0.0.1:85/api2/json"
|
||||
|
||||
# What a logged-in user must additionally HAVE. Attaching a floppy points a VM at
|
||||
# an arbitrary file on the host, so audit-level access is not enough.
|
||||
REQUIRED_PRIV = "Sys.Modify"
|
||||
|
||||
# Sessions are signed with a key generated at startup: a restart logs everyone
|
||||
# out, which for a homelab tool is a feature, not a limitation to engineer away.
|
||||
SESSION_KEY = secrets.token_bytes(32)
|
||||
SESSION_TTL = 8 * 3600
|
||||
|
||||
|
||||
def pvesh(*args, check=True):
|
||||
"""Run pvesh. Returns stdout. Raises RuntimeError with PVE's own message."""
|
||||
p = subprocess.run(
|
||||
["pvesh", *args], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if check and p.returncode != 0:
|
||||
raise RuntimeError((p.stderr or p.stdout).strip() or f"pvesh {args[1]} failed")
|
||||
return p.stdout
|
||||
|
||||
|
||||
def pvesh_json(*args):
|
||||
return json.loads(pvesh(*args, "--output-format", "json"))
|
||||
|
||||
|
||||
_REALMS = []
|
||||
|
||||
|
||||
def realms():
|
||||
"""Whatever PVE is configured with -- typically pam (local) and ad (LDAPS).
|
||||
|
||||
Cached for the life of the process: this is on the LOGIN page, so without it
|
||||
every unauthenticated hit paid 1.9s for a `pvesh` to list something that
|
||||
changes when someone adds an auth domain -- i.e. never, and a restart picks
|
||||
it up.
|
||||
"""
|
||||
if not _REALMS:
|
||||
_REALMS.extend(sorted(d["realm"] for d in pvesh_json("get", "/access/domains")))
|
||||
return _REALMS
|
||||
|
||||
|
||||
def pve_authenticate(userid, password):
|
||||
"""True if PVE accepts these credentials for this realm. No PAM/LDAP here."""
|
||||
data = urllib.parse.urlencode({"username": userid, "password": password}).encode()
|
||||
try:
|
||||
urllib.request.urlopen(f"{PVEDAEMON}/access/ticket", data=data, timeout=20).read()
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return False # 401 for a bad password, and for a disabled/expired account
|
||||
except urllib.error.URLError as e:
|
||||
# pvedaemon down is an outage, not a wrong password. Saying so avoids an
|
||||
# hour of retyping a password that was right all along.
|
||||
raise RuntimeError(f"PVE API unreachable: {e.reason}") from None
|
||||
|
||||
|
||||
def pve_authorized(userid):
|
||||
"""True if PVE's own ACL gives this user REQUIRED_PRIV on the whole tree."""
|
||||
perms = pvesh_json("get", "/access/permissions", "--userid", userid)
|
||||
return bool(perms.get("/", {}).get(REQUIRED_PRIV))
|
||||
|
||||
|
||||
def _mac(msg):
|
||||
return hmac.new(SESSION_KEY, msg, "sha256").digest()
|
||||
|
||||
|
||||
def session_new(userid):
|
||||
# The signature is HEX, not the raw digest: the parts are joined with '|'
|
||||
# and split back with rsplit, and 32 random bytes contain 0x7C ('|') about
|
||||
# 12% of the time -- which split the token inside its own signature and made
|
||||
# roughly one login in eight bounce straight back to the login page.
|
||||
msg = f"{userid}|{int(time.time()) + SESSION_TTL}".encode()
|
||||
return base64.urlsafe_b64encode(msg + b"|" + _mac(msg).hex().encode()).decode()
|
||||
|
||||
|
||||
def session_user(cookie):
|
||||
"""The userid a cookie proves, or None. Constant-time, expiry enforced."""
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cookie.encode())
|
||||
msg, sig = raw.rsplit(b"|", 1)
|
||||
if not hmac.compare_digest(sig, _mac(msg).hex().encode()):
|
||||
return None
|
||||
userid, exp = msg.decode().rsplit("|", 1)
|
||||
return userid if int(exp) > time.time() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def csrf_token(userid):
|
||||
return base64.urlsafe_b64encode(_mac(b"csrf|" + userid.encode())).decode()
|
||||
|
||||
|
||||
def floppy_spec(path):
|
||||
return f"-drive if=floppy,format=raw,file={path}"
|
||||
|
||||
|
||||
def args_with_floppy(args, path):
|
||||
"""Add or replace the floppy drive in an existing `args` string."""
|
||||
spec = floppy_spec(path)
|
||||
if FLOPPY_ARG.search(args or ""):
|
||||
return FLOPPY_ARG.sub(spec, args, count=1).strip()
|
||||
return f"{args} {spec}".strip() if args else spec
|
||||
|
||||
|
||||
def args_without_floppy(args):
|
||||
return " ".join(FLOPPY_ARG.sub("", args or "").split())
|
||||
|
||||
|
||||
def floppy_in_args(args):
|
||||
"""The image `args` will boot with, '' for a drive with no file, None for none."""
|
||||
m = FLOPPY_ARG.search(args or "")
|
||||
if not m:
|
||||
return None
|
||||
f = re.search(r"file=([^,\s]+)", m.group(0))
|
||||
return f.group(1) if f else ""
|
||||
|
||||
|
||||
def read_conf(path):
|
||||
"""Parse a PVE VM config off pmxcfs.
|
||||
|
||||
Snapshots are appended as [name] sections after the live config, so stop at
|
||||
the first one. Split on the FIRST colon only -- an `args` value is full of
|
||||
them (file=/mnt/pve/...).
|
||||
"""
|
||||
conf = {}
|
||||
try:
|
||||
f = open(path)
|
||||
except OSError:
|
||||
return conf
|
||||
with f:
|
||||
for line in f:
|
||||
if line.startswith("["):
|
||||
break
|
||||
key, sep, val = line.partition(":")
|
||||
if sep:
|
||||
conf[key.strip()] = val.strip()
|
||||
return conf
|
||||
|
||||
|
||||
def inserted_medium(node, vmid):
|
||||
"""What is in the drive RIGHT NOW, which is not what `args` says after a swap.
|
||||
|
||||
The single most expensive thing this app does: 1.9s for a VM on this node,
|
||||
3.6s when pveproxy has to forward it to another one. Only ask for VMs that
|
||||
actually have a floppy drive, and only when the cache has expired.
|
||||
"""
|
||||
try:
|
||||
out = pvesh("create", f"/nodes/{node}/qemu/{vmid}/monitor", "--command", "info block")
|
||||
except RuntimeError:
|
||||
return None
|
||||
m = re.search(r"^floppy0[^:]*:\s*(.*)$", out, re.M)
|
||||
if not m:
|
||||
return None
|
||||
val = m.group(1).strip()
|
||||
if val.startswith("[") or not val:
|
||||
return "" # [not inserted]
|
||||
return val.split(" (")[0]
|
||||
|
||||
|
||||
# A page load costs several pvesh round trips (a monitor query forwarded to
|
||||
# another node is 3.6s on its own), and clicking around re-pays them every time.
|
||||
# The TTL is deliberately longer than a human's click interval -- at 5s every
|
||||
# click still missed. Staleness is bounded to someone else running `qm` by hand,
|
||||
# because every action here clears the cache, so what you just did is never what
|
||||
# you see stale.
|
||||
CACHE_TTL = 30.0
|
||||
_CACHE = {"at": 0.0, "rows": None}
|
||||
|
||||
|
||||
def vms(fresh=False):
|
||||
"""The VM table, read off pmxcfs instead of asked for one VM at a time.
|
||||
|
||||
Every `pvesh` costs ~1.9s (Perl startup plus a cluster round trip), so the
|
||||
old shape -- one call for the list, then one per VM for its config, then one
|
||||
per running VM for its monitor -- made a four-guest page take ~16 seconds.
|
||||
/etc/pve is that same data replicated to every node, at file-read speed, so
|
||||
only the monitor round trips are left and those run in parallel.
|
||||
"""
|
||||
if not fresh and _CACHE["rows"] and time.time() - _CACHE["at"] < CACHE_TTL:
|
||||
return _CACHE["rows"]
|
||||
ids = json.load(open("/etc/pve/.vmlist"))["ids"]
|
||||
out = []
|
||||
for vmid, meta in ids.items():
|
||||
if meta.get("type") != "qemu":
|
||||
continue
|
||||
conf = read_conf(f"/etc/pve/nodes/{meta['node']}/qemu-server/{vmid}.conf")
|
||||
args = conf.get("args", "")
|
||||
out.append({
|
||||
"vmid": int(vmid),
|
||||
"name": conf.get("name", ""),
|
||||
"node": meta["node"],
|
||||
"args": args,
|
||||
"configured": floppy_in_args(args),
|
||||
})
|
||||
# Everything remaining is a pvesh round trip, so overlap them: run status
|
||||
# alongside the monitors, and ask the monitor ONLY about VMs that have a
|
||||
# floppy drive -- "what is in the drive" is meaningless for the others, and
|
||||
# it was two thirds of the calls here.
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
status = pool.submit(pvesh_json, "get", "/cluster/resources", "--type", "vm")
|
||||
media = {
|
||||
vm["vmid"]: pool.submit(inserted_medium, vm["node"], vm["vmid"])
|
||||
for vm in out
|
||||
if vm["configured"] is not None
|
||||
}
|
||||
running = {
|
||||
r["vmid"]: r.get("status") == "running"
|
||||
for r in status.result()
|
||||
if r.get("type") == "qemu"
|
||||
}
|
||||
for vm in out:
|
||||
vm["running"] = running.get(vm["vmid"], False)
|
||||
vm["inserted"] = media[vm["vmid"]].result() if vm["vmid"] in media else None
|
||||
rows = sorted(out, key=lambda v: v["vmid"])
|
||||
_CACHE.update(at=time.time(), rows=rows)
|
||||
return rows
|
||||
|
||||
|
||||
def images(image_dir):
|
||||
return sorted(
|
||||
f
|
||||
for f in os.listdir(image_dir)
|
||||
if f.lower().endswith(SUFFIXES) and not re.search(r"\s", f)
|
||||
)
|
||||
|
||||
|
||||
# Targets this UI is willing to write. It is an allowlist, not a blocklist,
|
||||
# because a phonebook entry is a thing the modem ACTS ON: `ppp` and `ssh:` make
|
||||
# it spawn a process, so a free-form target would be remote command execution
|
||||
# wearing a phone number. Anything new must be added here deliberately.
|
||||
PHONEBOOK_TARGET = re.compile(
|
||||
r"""^( ppp # hand the line to pppd
|
||||
| vm:\d+ # another line of the switchboard
|
||||
| ssh:[\w.@-]+(:\d+)? # ssh -tt to a host
|
||||
| (telnet:)?[\w.-]+:\d+ # raw TCP, or telnet with IAC handling
|
||||
)$""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def phonebook_read(path):
|
||||
try:
|
||||
return open(path).read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def phonebook_check(text):
|
||||
"""Return an error string, or None if every line is a comment or valid."""
|
||||
for n, line in enumerate(text.splitlines(), 1):
|
||||
body = line.split("#", 1)[0].split()
|
||||
if not body:
|
||||
continue
|
||||
if len(body) != 2:
|
||||
return f"line {n}: expected '<number> <target>'"
|
||||
if not body[0].isdigit():
|
||||
return f"line {n}: {body[0]!r} is not a phone number"
|
||||
if not PHONEBOOK_TARGET.match(body[1]):
|
||||
return f"line {n}: target {body[1]!r} is not one of ppp / vm:N / ssh:host / host:port / telnet:host:port"
|
||||
return None
|
||||
|
||||
|
||||
def phonebook_write(path, text):
|
||||
"""Write via a temp file in the same directory, so a failed write cannot
|
||||
leave the modem reading half a phonebook."""
|
||||
text = text.replace("\r\n", "\n")
|
||||
if not text.endswith("\n"):
|
||||
text += "\n"
|
||||
tmp = path + ".new"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
PAGE = """<!doctype html><meta charset=utf-8><title>PVE floppy</title>
|
||||
<style>
|
||||
body{{font:14px/1.5 system-ui,sans-serif;margin:2rem;max-width:60rem}}
|
||||
table{{border-collapse:collapse;width:100%}}
|
||||
td,th{{border-bottom:1px solid #ccc;padding:.5rem;text-align:left;vertical-align:top}}
|
||||
.msg{{padding:.6rem;background:#eef;border-left:3px solid #66c;margin-bottom:1rem}}
|
||||
.err{{background:#fee;border-color:#c66}}
|
||||
.note{{color:#666;font-size:.85em}}
|
||||
code{{background:#f4f4f4;padding:0 .2em}}
|
||||
</style>
|
||||
<h1>Floppy drives</h1>
|
||||
<p class=note>{user} —
|
||||
<form method=post action=/logout style=display:inline>
|
||||
<input type=hidden name=csrf value="{csrf}"><button>log out</button></form></p>
|
||||
<p class=note>PVE has no floppy in its UI or config schema; these live in
|
||||
<code>args</code>. Attaching or detaching a drive takes effect on the next
|
||||
<b>stop + start</b> (not a guest reboot). Inserting into an existing drive is live.</p>
|
||||
{msg}
|
||||
<table><tr><th>VM<th>drive in args<th>in the drive now<th>action</tr>
|
||||
{rows}
|
||||
</table>
|
||||
<p class=note>Images: {image_dir} — names with spaces are hidden (see the source).</p>
|
||||
|
||||
<h1>Modem phonebook</h1>
|
||||
<p class=note>{phonebook_path} — on pmxcfs, so every node sees it, and the
|
||||
switchboard re-reads it on the next dial. No restart, and a call in progress
|
||||
survives the edit.</p>
|
||||
<form method=post action=/phonebook>
|
||||
<input type=hidden name=csrf value="{csrf}">
|
||||
<textarea name=text rows=10 style="width:100%;font-family:monospace">{phonebook}</textarea>
|
||||
<br><button>Save phonebook</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
ROW = """<tr>
|
||||
<td>{vmid} {name}<br><span class=note>{node}, {state}</span>
|
||||
<td>{configured}
|
||||
<td>{inserted}
|
||||
<td><form method=post>
|
||||
<input type=hidden name=vmid value="{vmid}">
|
||||
<input type=hidden name=csrf value="{csrf}">
|
||||
<select name=image>{options}</select><br>
|
||||
{buttons}
|
||||
</form>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
LOGIN = """<!doctype html><meta charset=utf-8><title>PVE floppy</title>
|
||||
<style>
|
||||
body{{font:14px/1.5 system-ui,sans-serif;margin:4rem auto;max-width:22rem}}
|
||||
input,select,button{{width:100%;padding:.4rem;margin:.2rem 0;box-sizing:border-box}}
|
||||
.msg{{padding:.6rem;background:#fee;border-left:3px solid #c66}}
|
||||
.note{{color:#666;font-size:.85em}}
|
||||
</style>
|
||||
<h1>Floppy drives</h1>
|
||||
{msg}
|
||||
<form method=post action=/login>
|
||||
<label>User<input name=user autofocus></label>
|
||||
<label>Password<input name=password type=password></label>
|
||||
<label>Realm<select name=realm>{realms}</select></label>
|
||||
<button>Log in</button>
|
||||
</form>
|
||||
<p class=note>Proxmox accounts. <code>pam</code> is local to the node,
|
||||
<code>ad</code> is Samba AD over LDAPS. Needs {priv} on / in PVE.</p>
|
||||
"""
|
||||
|
||||
|
||||
def render_login(msg=""):
|
||||
opts = "".join(f"<option>{html.escape(r)}</option>" for r in realms())
|
||||
banner = f'<p class=msg>{html.escape(msg)}</p>' if msg else ""
|
||||
return LOGIN.format(msg=banner, realms=opts, priv=REQUIRED_PRIV)
|
||||
|
||||
|
||||
def render(image_dir, user, msg="", err=False):
|
||||
opts = "".join(f"<option>{html.escape(i)}</option>" for i in images(image_dir))
|
||||
rows = []
|
||||
for vm in vms():
|
||||
if vm["configured"] is None:
|
||||
buttons = '<button name=action value=attach>Attach drive</button>'
|
||||
configured = "<span class=note>none</span>"
|
||||
else:
|
||||
buttons = (
|
||||
'<button name=action value=attach>Set in args</button> '
|
||||
'<button name=action value=detach>Detach drive</button>'
|
||||
)
|
||||
configured = html.escape(os.path.basename(vm["configured"]) or "(empty)")
|
||||
if vm["running"] and vm["inserted"] is not None:
|
||||
buttons += (
|
||||
' <button name=action value=insert>Insert now</button>'
|
||||
' <button name=action value=eject>Eject</button>'
|
||||
)
|
||||
if vm["inserted"] is None:
|
||||
inserted = "<span class=note>no drive</span>"
|
||||
else:
|
||||
inserted = html.escape(os.path.basename(vm["inserted"]) or "(empty)")
|
||||
rows.append(
|
||||
ROW.format(
|
||||
vmid=vm["vmid"],
|
||||
name=html.escape(vm["name"]),
|
||||
node=html.escape(vm["node"]),
|
||||
state="running" if vm["running"] else "stopped",
|
||||
configured=configured,
|
||||
inserted=inserted,
|
||||
options=opts,
|
||||
buttons=buttons,
|
||||
csrf=csrf_token(user),
|
||||
)
|
||||
)
|
||||
banner = (
|
||||
f'<p class="msg{" err" if err else ""}">{html.escape(msg)}</p>' if msg else ""
|
||||
)
|
||||
return PAGE.format(
|
||||
phonebook=html.escape(phonebook_read(Handler.phonebook)),
|
||||
phonebook_path=html.escape(Handler.phonebook),
|
||||
msg=banner,
|
||||
rows="".join(rows),
|
||||
image_dir=html.escape(image_dir),
|
||||
user=html.escape(user),
|
||||
csrf=csrf_token(user),
|
||||
)
|
||||
|
||||
|
||||
def act(image_dir, action, vmid, image):
|
||||
"""Perform one action. Every input is re-validated against live state.
|
||||
|
||||
fresh=True on purpose: acting on a cached view of the cluster is exactly the
|
||||
case the cache must not cover, and the redirect afterwards has to show the
|
||||
result, not the state from before the click.
|
||||
"""
|
||||
_CACHE["rows"] = None
|
||||
vm = next((v for v in vms(fresh=True) if str(v["vmid"]) == str(vmid)), None)
|
||||
if not vm:
|
||||
raise RuntimeError(f"no such VM: {vmid}")
|
||||
path = None
|
||||
if action in ("attach", "insert"):
|
||||
if image not in images(image_dir): # no path traversal, no arbitrary host file
|
||||
raise RuntimeError(f"unknown image: {image}")
|
||||
path = os.path.join(image_dir, image)
|
||||
base = f"/nodes/{vm['node']}/qemu/{vm['vmid']}"
|
||||
|
||||
if action == "attach":
|
||||
pvesh("set", f"{base}/config", "--args", args_with_floppy(vm["args"], path))
|
||||
return f"{vmid}: args now carry {image}. Stop and start the VM to get the drive."
|
||||
if action == "detach":
|
||||
rest = args_without_floppy(vm["args"])
|
||||
if rest:
|
||||
pvesh("set", f"{base}/config", "--args", rest)
|
||||
else:
|
||||
pvesh("set", f"{base}/config", "--delete", "args")
|
||||
return f"{vmid}: floppy removed from args. Takes effect on stop + start."
|
||||
if action == "insert":
|
||||
pvesh("create", f"{base}/monitor", "--command", f"change floppy0 {path}")
|
||||
return f"{vmid}: inserted {image}."
|
||||
if action == "eject":
|
||||
pvesh("create", f"{base}/monitor", "--command", "eject floppy0")
|
||||
return f"{vmid}: ejected."
|
||||
raise RuntimeError(f"unknown action: {action}")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
image_dir = "/mnt/pve/laptop/template/iso"
|
||||
secure = False # set when serving TLS, so the cookie can demand it
|
||||
|
||||
phonebook = "/etc/pve/retro-phonebook"
|
||||
|
||||
def _send(self, code, body, headers=()):
|
||||
data = body.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
for k, v in headers:
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _redirect(self, msg="", err=False):
|
||||
q = urllib.parse.urlencode({"msg": msg, **({"err": "1"} if err else {})})
|
||||
self._send(303, "", [("Location", f"/?{q}" if msg else "/")])
|
||||
|
||||
def _user(self):
|
||||
cookie = SimpleCookie(self.headers.get("Cookie", "")).get("floppy")
|
||||
return session_user(cookie.value) if cookie else None
|
||||
|
||||
def _form(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
return urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.split("?")[0] != "/":
|
||||
return self._send(404, "not found")
|
||||
user = self._user()
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
try:
|
||||
page = (
|
||||
render(self.image_dir, user, q.get("msg", [""])[0], "err" in q)
|
||||
if user
|
||||
else render_login(q.get("msg", [""])[0])
|
||||
)
|
||||
except Exception as e: # a broken pvesh must show up, not 500 silently
|
||||
page = f"<h1>Floppy drives</h1><p class=msg>pvesh failed: {html.escape(str(e))}</p>"
|
||||
self._send(200, page)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
form = self._form()
|
||||
|
||||
if path == "/login":
|
||||
user = form.get("user", [""])[0].strip()
|
||||
# Everyone types the name they use everywhere else, `me@ad`, which
|
||||
# would become `me@ad@ad` and fail as "no such user". A sAMAccountName
|
||||
# cannot contain '@', so dropping a realm suffix is unambiguous.
|
||||
user = user.split("@")[0]
|
||||
realm = form.get("realm", [""])[0]
|
||||
if realm not in realms() or not user:
|
||||
return self._send(200, render_login("pick a valid realm"))
|
||||
userid = f"{user}@{realm}"
|
||||
try:
|
||||
ok = pve_authenticate(userid, form.get("password", [""])[0])
|
||||
except RuntimeError as e:
|
||||
return self._send(200, render_login(str(e)))
|
||||
if not ok:
|
||||
# One message for both cases on purpose: a distinct "no such
|
||||
# user" would enumerate the directory for anyone who can reach this.
|
||||
return self._send(200, render_login("login failed"))
|
||||
if not pve_authorized(userid):
|
||||
return self._send(200, render_login(f"{userid} lacks {REQUIRED_PRIV} on /"))
|
||||
cookie = (
|
||||
f"floppy={session_new(userid)}; Path=/; HttpOnly; SameSite=Strict"
|
||||
+ ("; Secure" if self.secure else "")
|
||||
)
|
||||
return self._send(303, "", [("Location", "/"), ("Set-Cookie", cookie)])
|
||||
|
||||
user = self._user()
|
||||
if not user:
|
||||
return self._send(200, render_login("session expired"))
|
||||
# SameSite=Strict already blocks cross-site posts in current browsers;
|
||||
# the token is what covers the ones that do not implement it.
|
||||
if not hmac.compare_digest(form.get("csrf", [""])[0], csrf_token(user)):
|
||||
return self._send(400, "bad csrf token")
|
||||
|
||||
if path == "/phonebook":
|
||||
text = form.get("text", [""])[0]
|
||||
bad = phonebook_check(text)
|
||||
if bad:
|
||||
return self._redirect(bad, err=True)
|
||||
try:
|
||||
phonebook_write(self.phonebook, text)
|
||||
except OSError as e:
|
||||
return self._redirect(f"could not write phonebook: {e}", err=True)
|
||||
print(f"{user} saved phonebook", flush=True)
|
||||
return self._redirect("phonebook saved")
|
||||
|
||||
if path == "/logout":
|
||||
return self._send(
|
||||
303, "", [("Location", "/"), ("Set-Cookie", "floppy=; Path=/; Max-Age=0")]
|
||||
)
|
||||
if path != "/":
|
||||
return self._send(404, "not found")
|
||||
|
||||
action = form.get("action", [""])[0]
|
||||
vmid = form.get("vmid", [""])[0]
|
||||
try:
|
||||
msg = act(self.image_dir, action, vmid, form.get("image", [""])[0])
|
||||
_CACHE["rows"] = None # the next render must show what just happened
|
||||
# Who did what to which VM belongs in the journal -- this app hands
|
||||
# out an action nothing else in PVE records.
|
||||
print(f"{user} {action} vm {vmid}: {msg}", flush=True)
|
||||
self._redirect(msg)
|
||||
except Exception as e:
|
||||
print(f"{user} {action} vm {vmid} FAILED: {e}", flush=True)
|
||||
self._redirect(str(e), err=True)
|
||||
|
||||
def log_message(self, fmt, *a):
|
||||
pass # journald already has the systemd unit's own noise
|
||||
|
||||
|
||||
def selftest():
|
||||
# sessions: a valid one round-trips, a tampered or expired one does not
|
||||
s = session_new("me@ad")
|
||||
assert session_user(s) == "me@ad"
|
||||
# Repeated on purpose: a raw-digest signature only broke ~12% of the time,
|
||||
# so one round trip passed the test and still logged people out at random.
|
||||
for _ in range(300):
|
||||
assert session_user(session_new("me@ad")) == "me@ad", "flaky session token"
|
||||
assert session_user(s[:-4] + "AAAA") is None
|
||||
assert session_user("garbage") is None
|
||||
global SESSION_TTL
|
||||
SESSION_TTL, old = -1, SESSION_TTL
|
||||
assert session_user(session_new("me@ad")) is None, "expiry not enforced"
|
||||
SESSION_TTL = old
|
||||
assert csrf_token("me@ad") != csrf_token("you@ad")
|
||||
|
||||
a = "-drive if=floppy,format=raw,file=/iso/a.img"
|
||||
assert floppy_in_args("") is None
|
||||
assert floppy_in_args(a) == "/iso/a.img"
|
||||
assert args_with_floppy("", "/iso/a.img") == a
|
||||
# replaces in place, does not duplicate, and leaves unrelated args alone
|
||||
mixed = f"-cpu foo {a} -boot order=a"
|
||||
swapped = args_with_floppy(mixed, "/iso/b.img")
|
||||
assert swapped == "-cpu foo -drive if=floppy,format=raw,file=/iso/b.img -boot order=a", swapped
|
||||
assert args_without_floppy(mixed) == "-cpu foo -boot order=a"
|
||||
assert args_without_floppy(a) == ""
|
||||
assert floppy_in_args("-drive if=floppy") == ""
|
||||
|
||||
# phonebook: the allowlist is the security boundary, so test what it REFUSES
|
||||
assert phonebook_check("# just a comment\n5551212 ppp\n5551102 vm:102\n") is None
|
||||
assert phonebook_check("5552323 telnet:bbs.example.com:23\n") is None
|
||||
assert phonebook_check("5551000 ssh:[email protected]:22\n") is None
|
||||
assert phonebook_check("5551212 192.168.10.127:6060 # trailing comment\n") is None
|
||||
assert phonebook_check("5551212 exec:/bin/sh\n"), "exec: must be refused"
|
||||
assert phonebook_check("5551212 ppp; rm -rf /\n"), "shell metacharacters must be refused"
|
||||
assert phonebook_check("notanumber ppp\n"), "non-numeric number must be refused"
|
||||
assert phonebook_check("5551212\n"), "a target is required"
|
||||
|
||||
# config parsing: args is full of colons, and snapshots follow the live config
|
||||
import tempfile
|
||||
conf = tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False)
|
||||
conf.write(
|
||||
"name: retro-pdc\n"
|
||||
"args: -drive if=floppy,format=raw,file=/mnt/pve/laptop/template/iso/a.img\n"
|
||||
"cores: 1\n"
|
||||
"[snap1]\n"
|
||||
"args: -drive if=floppy,format=raw,file=/WRONG.img\n"
|
||||
)
|
||||
conf.close()
|
||||
c = read_conf(conf.name)
|
||||
assert c["name"] == "retro-pdc"
|
||||
assert c["args"].endswith("/iso/a.img"), c["args"] # colons survived
|
||||
assert floppy_in_args(c["args"]).endswith("/iso/a.img")
|
||||
assert c["cores"] == "1"
|
||||
os.unlink(conf.name)
|
||||
assert read_conf("/nonexistent/vm.conf") == {} # missing is empty, not fatal
|
||||
print("ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--listen", default="0.0.0.0")
|
||||
ap.add_argument("--port", type=int, default=8088)
|
||||
ap.add_argument("--image-dir", default=Handler.image_dir)
|
||||
ap.add_argument("--phonebook", default=Handler.phonebook,
|
||||
help="modem phonebook; on pmxcfs so every node sees it")
|
||||
# The node's own ACME certificate (role pve_acme), the one pveproxy serves.
|
||||
ap.add_argument("--cert", default="/etc/pve/local/pveproxy-ssl.pem")
|
||||
ap.add_argument("--key", default="/etc/pve/local/pveproxy-ssl.key")
|
||||
ap.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
help="serve plain HTTP. There is a password form on this app, so this "
|
||||
"puts credentials on the LAN in clear -- hence opt-in, not fallback.",
|
||||
)
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
o = ap.parse_args()
|
||||
if o.selftest:
|
||||
selftest()
|
||||
raise SystemExit(0)
|
||||
|
||||
Handler.image_dir = o.image_dir
|
||||
Handler.phonebook = o.phonebook
|
||||
srv = ThreadingHTTPServer((o.listen, o.port), Handler)
|
||||
have_cert = os.path.exists(o.cert) and os.path.exists(o.key)
|
||||
if not have_cert and not o.insecure:
|
||||
# Fail closed: a login form on cleartext HTTP is worse than no service.
|
||||
raise SystemExit(f"no certificate at {o.cert} -- pass --insecure to serve anyway")
|
||||
if have_cert:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(o.cert, o.key)
|
||||
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
|
||||
Handler.secure = True
|
||||
print(f"listening on {'https' if Handler.secure else 'http'}://{o.listen}:{o.port}", flush=True)
|
||||
srv.serve_forever()
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart pve-floppy-ui
|
||||
ansible.builtin.systemd_service:
|
||||
name: pve-floppy-ui
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
# Deploy on ONE node only -- see defaults for why one instance covers the cluster.
|
||||
|
||||
- name: Install the floppy UI
|
||||
ansible.builtin.copy:
|
||||
src: floppy-ui.py
|
||||
dest: /usr/local/bin/pve-floppy-ui
|
||||
mode: "0755"
|
||||
notify: Restart pve-floppy-ui
|
||||
|
||||
- name: Self-check the args parsing
|
||||
# The one piece of real logic in the app: it rewrites the `args` string of a
|
||||
# live VM, so a bad edit there corrupts a VM config. Cheap to assert, and it
|
||||
# also proves the node's python can run the file at all.
|
||||
ansible.builtin.command:
|
||||
cmd: /usr/local/bin/pve-floppy-ui --selftest
|
||||
changed_when: false
|
||||
|
||||
- name: Install the unit
|
||||
ansible.builtin.template:
|
||||
src: pve-floppy-ui.service.j2
|
||||
dest: /etc/systemd/system/pve-floppy-ui.service
|
||||
mode: "0644"
|
||||
notify: Restart pve-floppy-ui
|
||||
|
||||
- name: Enable and start it
|
||||
ansible.builtin.systemd_service:
|
||||
name: pve-floppy-ui
|
||||
state: started
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Floppy drive manager for PVE VMs
|
||||
Documentation=file:///usr/local/bin/pve-floppy-ui
|
||||
# pvesh talks to pveproxy, which needs the cluster fs mounted.
|
||||
After=pve-cluster.service pveproxy.service
|
||||
Wants=pve-cluster.service
|
||||
|
||||
[Service]
|
||||
# Runs as root deliberately: the VM `args` option is root@pam-only, so there is
|
||||
# no lesser identity that can do this job -- not even an API token. Root is also
|
||||
# what can read the pveproxy TLS key it serves with; the app refuses to start
|
||||
# without a certificate rather than put a login form on cleartext HTTP.
|
||||
ExecStart=/usr/local/bin/pve-floppy-ui --listen {{ pve_floppy_listen }} --port {{ pve_floppy_port }} --image-dir {{ pve_floppy_image_dir }} --phonebook {{ pve_floppy_phonebook }}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
# HA-managed guests. DELIBERATELY SHORT: the cluster's stated posture is "no HA,
|
||||
# guests are disposable" (see ../../../CLAUDE.md). Anything listed here is an
|
||||
# exception that has earned it, and the reason belongs in the comment.
|
||||
pve_ha_resources:
|
||||
# vyos-rtr. It is the gateway for BOTH SDN VNets and an OSPF speaker, so losing
|
||||
# it takes labnet+retronet offline (the main LAN is unaffected — the NEC IX is
|
||||
# its gateway). Disk is on pve-rg (DRBD, place-count 2), so it can start on any
|
||||
# node; pve3 would attach diskless.
|
||||
# NOTE this buys crash-RESTART (~1-3 min outage), not seamless failover. Real
|
||||
# gateway HA would be a second VyOS with VRRP.
|
||||
- sid: "vm:100"
|
||||
state: started
|
||||
max_restart: 3
|
||||
max_relocate: 2
|
||||
|
||||
# --- Watchdog ---------------------------------------------------------------
|
||||
# ⚠ WHY THIS MATTERS: adding ANY HA resource arms fencing cluster-wide. PVE's
|
||||
# default is `softdog`, a SOFTWARE watchdog — a kernel timer, which CANNOT fire if
|
||||
# the kernel itself is frozen. That is precisely the failure this cluster has
|
||||
# actually seen (pve2's Raven Ridge idle freeze). A hardware watchdog is
|
||||
# independent silicon and fires regardless.
|
||||
#
|
||||
# Verified available 2026-07-26:
|
||||
# pve1 (Intel i3-6100U) -> iTCO_wdt (timeout 30s)
|
||||
# pve2/pve3 (Ryzen 2400GE) -> sp5100_tco (timeout 60s)
|
||||
# Set per-host in inventory host_vars; empty string = leave PVE's softdog default.
|
||||
pve_ha_watchdog_module: ""
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# Deliberately does NOT reboot. Swapping the watchdog under an armed cluster is a
|
||||
# manual, ordered operation (see ../../../README-ha.md); a surprise rolling reboot
|
||||
# of all three nodes is exactly what you do not want an idempotent play to do.
|
||||
- name: reboot required for watchdog
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
Watchdog config changed on {{ inventory_hostname }}. It takes effect on the
|
||||
NEXT REBOOT of this node. Until then fencing still uses softdog, which cannot
|
||||
fence a frozen kernel. See proxmox/README-ha.md for the live-swap procedure.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
# Proxmox HA. Read roles/pve_ha/defaults/main.yml first — it explains why the
|
||||
# resource list is short and why the watchdog choice is not cosmetic.
|
||||
|
||||
# --- Watchdog ---------------------------------------------------------------
|
||||
# Done BEFORE registering resources: adding a resource arms fencing, and arming
|
||||
# fencing on a software watchdog is the weakest configuration.
|
||||
- name: Select the hardware watchdog module
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/default/pve-ha-manager
|
||||
regexp: '^#?\s*WATCHDOG_MODULE='
|
||||
line: "WATCHDOG_MODULE={{ pve_ha_watchdog_module }}"
|
||||
create: false
|
||||
when: pve_ha_watchdog_module | length > 0
|
||||
notify: reboot required for watchdog
|
||||
tags: [ha, watchdog]
|
||||
|
||||
# watchdog-mux opens /dev/watchdog, which belongs to whichever watchdog registered
|
||||
# FIRST. PVE loads softdog at boot, so it wins and the hardware module ends up as
|
||||
# an unused watchdog1. Blacklisting softdog is what actually makes the hardware
|
||||
# one take effect — setting WATCHDOG_MODULE alone does nothing.
|
||||
- name: Blacklist softdog so the hardware watchdog claims /dev/watchdog
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/modprobe.d/pve-ha-watchdog.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by ansible (roles/pve_ha). See that role for the reasoning.
|
||||
# softdog cannot fence a frozen kernel; blacklisting it lets
|
||||
# {{ pve_ha_watchdog_module }} register as watchdog0 and own /dev/watchdog.
|
||||
blacklist softdog
|
||||
when: pve_ha_watchdog_module | length > 0
|
||||
notify: reboot required for watchdog
|
||||
tags: [ha, watchdog]
|
||||
|
||||
# ⚠ NOT DONE LIVE ON PURPOSE. Swapping the watchdog on a running node means
|
||||
# stopping watchdog-mux and unloading softdog while fencing is armed — get the
|
||||
# order wrong and the node self-fences (reboots). The safe live procedure is
|
||||
# documented in ../../README-ha.md; otherwise it simply takes effect on the next
|
||||
# reboot, which is why the handler only WARNS rather than rebooting anything.
|
||||
|
||||
# --- Resources ---------------------------------------------------------------
|
||||
- name: Read current HA resources
|
||||
ansible.builtin.command: ha-manager status
|
||||
register: pve_ha_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
run_once: true
|
||||
tags: [ha]
|
||||
|
||||
- name: Register HA resources
|
||||
ansible.builtin.command: >-
|
||||
ha-manager add {{ item.sid }}
|
||||
--state {{ item.state }}
|
||||
--max_restart {{ item.max_restart }}
|
||||
--max_relocate {{ item.max_relocate }}
|
||||
loop: "{{ pve_ha_resources }}"
|
||||
loop_control:
|
||||
label: "{{ item.sid }}"
|
||||
# `ha-manager add` errors if the resource already exists, so gate on the status
|
||||
# output. This is what keeps a second run at changed=0.
|
||||
when: "'service ' ~ item.sid not in (pve_ha_status.stdout | default(''))"
|
||||
run_once: true
|
||||
tags: [ha]
|
||||
|
||||
- name: Verify HA is armed and the resource is known
|
||||
ansible.builtin.command: ha-manager status
|
||||
register: pve_ha_verify
|
||||
changed_when: false
|
||||
run_once: true
|
||||
tags: [ha]
|
||||
|
||||
- name: Show HA state
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ pve_ha_verify.stdout_lines }}"
|
||||
run_once: true
|
||||
tags: [ha]
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
- name: Update grub
|
||||
# Rewrites /boot/grub/grub.cfg. Takes effect on the NEXT boot only -- this
|
||||
# role intentionally never reboots a node on its own.
|
||||
ansible.builtin.command:
|
||||
cmd: update-grub
|
||||
changed_when: true
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# Per-host kernel cmdline additions, driven by `pve_kernel_cmdline_extra` in the
|
||||
# inventory. Currently only pve2 sets it (Raven Ridge random-freeze workaround);
|
||||
# pve3 is identical silicon but is deliberately left alone -- see inventory.
|
||||
#
|
||||
# This is a no-op on hosts that do not define the variable.
|
||||
|
||||
- name: Set GRUB_CMDLINE_LINUX_DEFAULT
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/default/grub
|
||||
regexp: '^GRUB_CMDLINE_LINUX_DEFAULT='
|
||||
line: 'GRUB_CMDLINE_LINUX_DEFAULT="quiet {{ pve_kernel_cmdline_extra }}"'
|
||||
backup: true
|
||||
when: pve_kernel_cmdline_extra is defined and pve_kernel_cmdline_extra | length > 0
|
||||
notify: Update grub
|
||||
|
||||
- name: Check which extra params are actually active
|
||||
# /proc/cmdline is the only honest source: editing grub without running
|
||||
# update-grub, or rebooting, silently leaves the fix inactive.
|
||||
ansible.builtin.command:
|
||||
cmd: cat /proc/cmdline
|
||||
register: _cmdline
|
||||
changed_when: false
|
||||
|
||||
- name: Warn when a configured param is not yet live
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ inventory_hostname }}: "{{ item }}" is configured but NOT active in
|
||||
/proc/cmdline -- a reboot is required for it to take effect.
|
||||
loop: "{{ pve_kernel_cmdline_extra.split() }}"
|
||||
when:
|
||||
- pve_kernel_cmdline_extra is defined and pve_kernel_cmdline_extra | length > 0
|
||||
- item not in _cmdline.stdout
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
# LINSTOR/DRBD replacing Ceph. Ceph was tried on HDD OSDs + SSD DB and was far
|
||||
# too slow; LINSTOR wins here through DATA LOCALITY — a VM reads from the copy on
|
||||
# its own node instead of pulling every block across a single flat 1G LAN.
|
||||
|
||||
# --- LINBIT public repo (free, no subscription) ---
|
||||
pve_linstor_repo_suite: proxmox-9
|
||||
pve_linstor_repo_url: "http://packages.linbit.com/public/"
|
||||
pve_linstor_repo_key_url: "https://packages.linbit.com/package-signing-pubkey.asc"
|
||||
pve_linstor_keyring: /usr/share/keyrings/linbit-keyring.gpg
|
||||
|
||||
# --- Roles ---
|
||||
# Controller on pve1: most RAM (16G vs 8G). Single controller, no HA — this
|
||||
# cluster deliberately holds nothing critical.
|
||||
pve_linstor_controller: pve1
|
||||
|
||||
# --- Storage pools ---
|
||||
# SSD = VM disks (fast). Built from the space the installer left free beyond the
|
||||
# 60G `pve` VG: ~107G on pve1's SATA SSD, ~178G on the ThinkCentres' NVMe.
|
||||
pve_linstor_ssd_vg: vg_ssd
|
||||
pve_linstor_ssd_pool: ssd
|
||||
|
||||
# HDD = bulk. Whole 1TB spindle. pve2/pve3 still carry STALE CEPH VGs here that
|
||||
# must be wiped first; pve1's was already cleared.
|
||||
pve_linstor_hdd_vg: vg_hdd
|
||||
pve_linstor_hdd_pool: hdd
|
||||
pve_linstor_hdd_disk: /dev/sda
|
||||
|
||||
# --- Resource groups ---
|
||||
# place-count 2 = two replicas across three nodes: survives losing any one node
|
||||
# while costing 2x rather than 3x capacity.
|
||||
#
|
||||
# ONE PER STORAGE POOL. A resource group binds to a single pool, so without an
|
||||
# hdd group the ~2.7TB of spinning disk is provisioned in LINSTOR but completely
|
||||
# unreachable from Proxmox — pools exist, nothing can allocate from them.
|
||||
pve_linstor_place_count: 2
|
||||
pve_linstor_resource_groups:
|
||||
- name: pve-rg # fast: VM/CT root disks
|
||||
pool: "{{ pve_linstor_ssd_pool }}"
|
||||
content: images,rootdir
|
||||
- name: pve-rg-hdd # bulk: large/cold volumes on the 1TB spindles
|
||||
pool: "{{ pve_linstor_hdd_pool }}"
|
||||
content: images,rootdir
|
||||
|
||||
# Safety: wiping a disk is destructive and irreversible. Must be set explicitly.
|
||||
pve_linstor_wipe_hdd: false
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
# LINSTOR cluster: controller on one node, satellites everywhere, storage pools,
|
||||
# resource group, and the PVE storage entry.
|
||||
#
|
||||
# All linstor CLI calls run ONCE against the controller — LINSTOR is itself a
|
||||
# cluster-wide database, so repeating them per-host would just race.
|
||||
|
||||
- name: Enable and start the satellite on every node
|
||||
ansible.builtin.systemd_service:
|
||||
name: linstor-satellite
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Enable and start the controller
|
||||
ansible.builtin.systemd_service:
|
||||
name: linstor-controller
|
||||
enabled: true
|
||||
state: started
|
||||
when: inventory_hostname == pve_linstor_controller
|
||||
|
||||
- name: Point the client at the controller
|
||||
# Without this, `linstor` talks to localhost and fails on the satellites.
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/linstor/linstor-client.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by Ansible (role pve_linstor).
|
||||
[global]
|
||||
controllers=linstor://{{ hostvars[pve_linstor_controller].ansible_host }}
|
||||
|
||||
- name: Wait for the controller API
|
||||
ansible.builtin.command:
|
||||
cmd: linstor node list
|
||||
register: _lin_ready
|
||||
until: _lin_ready.rc == 0
|
||||
retries: 24
|
||||
delay: 5
|
||||
changed_when: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── nodes ─────────────────────────────────────────────────────────────────
|
||||
- name: Register each node with LINSTOR
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
linstor node create {{ item }} {{ hostvars[item].ansible_host }}
|
||||
--node-type {{ 'Combined' if item == pve_linstor_controller else 'Satellite' }}
|
||||
loop: "{{ groups['pve'] }}"
|
||||
register: _node_create
|
||||
changed_when: "'successfully' in (_node_create.stdout | default('') | lower)"
|
||||
failed_when:
|
||||
- _node_create.rc != 0
|
||||
- "'already exists' not in (_node_create.stdout | default('') + _node_create.stderr | default('')) | lower"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Wait for all nodes to come ONLINE
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
linstor -m --output-version v1 node list \
|
||||
| python3 -c "import json,sys; print(all(n['connection_status']=='ONLINE' for n in json.load(sys.stdin)[0]))"
|
||||
executable: /bin/bash
|
||||
register: _nodes_online
|
||||
until: "'True' in _nodes_online.stdout"
|
||||
retries: 24
|
||||
delay: 5
|
||||
changed_when: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── storage pools ─────────────────────────────────────────────────────────
|
||||
- name: Create the storage pools
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
linstor storage-pool create lvm {{ item.0 }} {{ item.1.pool }} {{ item.1.vg }}
|
||||
loop: "{{ groups['pve'] | product([
|
||||
{'pool': pve_linstor_ssd_pool, 'vg': pve_linstor_ssd_vg},
|
||||
{'pool': pve_linstor_hdd_pool, 'vg': pve_linstor_hdd_vg}]) | list }}"
|
||||
register: _sp
|
||||
changed_when: "'successfully' in (_sp.stdout | default('') | lower)"
|
||||
failed_when:
|
||||
- _sp.rc != 0
|
||||
- "'already exists' not in (_sp.stdout | default('') + _sp.stderr | default('')) | lower"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── resource groups ───────────────────────────────────────────────────────
|
||||
# One per storage pool — a resource group binds to exactly one pool, so the hdd
|
||||
# pool needs its own or that capacity is unusable from Proxmox.
|
||||
- name: Create the resource groups
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
linstor resource-group create {{ item.name }}
|
||||
--storage-pool {{ item.pool }}
|
||||
--place-count {{ item.place_count | default(pve_linstor_place_count) }}
|
||||
loop: "{{ pve_linstor_resource_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }} -> {{ item.pool }}"
|
||||
register: _rg
|
||||
changed_when: "'successfully' in (_rg.stdout | default('') | lower)"
|
||||
failed_when:
|
||||
- _rg.rc != 0
|
||||
- "'already exists' not in (_rg.stdout | default('') + _rg.stderr | default('')) | lower"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Read existing volume-group definitions
|
||||
# `linstor volume-group create` is NOT idempotent: every call APPENDS another
|
||||
# volume number. Running the play three times left pve-rg with VlmNrs 0,1,2, so
|
||||
# PVE then failed every disk create with "has 3 Volume groups, but only 1 sizes
|
||||
# were provided". Check before creating.
|
||||
ansible.builtin.shell:
|
||||
# Count table rows. NOTE the leading char class: linstor draws its tables
|
||||
# with BOX-DRAWING pipes (U+2502), not ASCII '|', so a '^\\|' pattern never
|
||||
# matches and the count is always 0 -- which silently appends a NEW volume
|
||||
# group on every run.
|
||||
cmd: "linstor volume-group list {{ item.name }} 2>/dev/null | grep -cE '^.[[:space:]]*[0-9]+[[:space:]]' || true"
|
||||
loop: "{{ pve_linstor_resource_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _vg_count
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Create a volume group where none exists
|
||||
ansible.builtin.command:
|
||||
cmd: "linstor volume-group create {{ item.item.name }}"
|
||||
loop: "{{ _vg_count.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.name }} (has {{ item.stdout | default('?') | trim }})"
|
||||
when: (item.stdout | default('0') | trim | int) == 0
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
# ── PVE storage entries ───────────────────────────────────────────────────
|
||||
- name: Register each resource group as PVE storage
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesm add drbd {{ item.name }}
|
||||
--content {{ item.content | default('images,rootdir') }}
|
||||
--controller {{ hostvars[pve_linstor_controller].ansible_host }}
|
||||
--resourcegroup {{ item.name }}
|
||||
loop: "{{ pve_linstor_resource_groups }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _pvesm
|
||||
changed_when: _pvesm.rc == 0
|
||||
failed_when:
|
||||
- _pvesm.rc != 0
|
||||
- "'already defined' not in (_pvesm.stderr | default('') + _pvesm.stdout | default(''))"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Report LINSTOR state
|
||||
ansible.builtin.shell:
|
||||
cmd: "linstor storage-pool list; echo; linstor resource-group list; echo; pvesm status"
|
||||
register: _lin_state
|
||||
changed_when: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_linstor_controller }}"
|
||||
|
||||
- name: Show it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _lin_state.stdout_lines }}"
|
||||
run_once: true
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# LINSTOR/DRBD hyperconverged storage. Split into stages so the destructive part
|
||||
# (disk wiping) is separately gated and cannot run by accident.
|
||||
|
||||
- name: Install packages
|
||||
ansible.builtin.import_tasks: packages.yml
|
||||
tags: [linstor_pkgs]
|
||||
|
||||
- name: Prepare backing storage (LVM)
|
||||
ansible.builtin.import_tasks: storage.yml
|
||||
tags: [linstor_storage]
|
||||
|
||||
- name: Configure the LINSTOR cluster
|
||||
ansible.builtin.import_tasks: linstor.yml
|
||||
tags: [linstor_config]
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
# LINBIT's public repo. Free and needs no subscription — the subscription only
|
||||
# buys support and their prebuilt kernel modules; drbd-dkms builds locally.
|
||||
|
||||
- name: Install the LINBIT signing key
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ pve_linstor_repo_key_url }}"
|
||||
dest: /tmp/linbit-pubkey.asc
|
||||
mode: "0644"
|
||||
retries: 3
|
||||
delay: 10
|
||||
|
||||
- name: Convert the key to a keyring
|
||||
ansible.builtin.shell:
|
||||
cmd: "gpg --dearmor < /tmp/linbit-pubkey.asc > {{ pve_linstor_keyring }}"
|
||||
creates: "{{ pve_linstor_keyring }}"
|
||||
|
||||
- name: Add the LINBIT repository
|
||||
ansible.builtin.deb822_repository:
|
||||
name: linbit
|
||||
types: [deb]
|
||||
uris: "{{ pve_linstor_repo_url }}"
|
||||
suites: "{{ pve_linstor_repo_suite }}"
|
||||
components: [drbd-9]
|
||||
signed_by: "{{ pve_linstor_keyring }}"
|
||||
enabled: true
|
||||
state: present
|
||||
register: _linbit_repo
|
||||
|
||||
- name: Update apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
register: _apt
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _apt is succeeded
|
||||
|
||||
- name: Install DRBD + LINSTOR packages
|
||||
# drbd-dkms COMPILES a kernel module against the running kernel, so the
|
||||
# headers must match. This is the step most likely to fail on a flaky uplink,
|
||||
# hence the retries.
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- "pve-headers-{{ ansible_facts['kernel'] }}"
|
||||
- drbd-dkms
|
||||
- drbd-utils
|
||||
- linstor-controller
|
||||
- linstor-satellite
|
||||
- linstor-client
|
||||
- linstor-proxmox
|
||||
state: present
|
||||
register: _linstor_pkgs
|
||||
retries: 2
|
||||
delay: 30
|
||||
until: _linstor_pkgs is succeeded
|
||||
|
||||
- name: Load the DRBD module
|
||||
ansible.builtin.command:
|
||||
cmd: modprobe drbd
|
||||
changed_when: false
|
||||
|
||||
- name: Check which DRBD version is actually LOADED
|
||||
ansible.builtin.shell:
|
||||
cmd: "cat /proc/drbd 2>/dev/null | head -1"
|
||||
register: _drbd_ver
|
||||
changed_when: false
|
||||
|
||||
# The kernel ships an IN-TREE drbd 8.4. If anything loaded it before drbd-dkms
|
||||
# was installed, modprobe is a no-op and the running module stays 8.4 even
|
||||
# though dkms built 9.x correctly (modinfo will happily report 9.x from
|
||||
# .../updates/dkms/drbd.ko). LINSTOR needs 9. Swap it live when nothing is using
|
||||
# it; refuse and demand a reboot when something is.
|
||||
- name: Check the module refcount before swapping
|
||||
ansible.builtin.shell:
|
||||
cmd: "lsmod | awk '$1==\"drbd\" {print $3}' | head -1"
|
||||
register: _drbd_refs
|
||||
changed_when: false
|
||||
when: "'version: 9' not in _drbd_ver.stdout"
|
||||
|
||||
- name: Refuse to swap a DRBD module that is in use
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
In-tree DRBD {{ _drbd_ver.stdout }} is loaded and IN USE
|
||||
(refcount {{ _drbd_refs.stdout | default('?') }}). Reboot
|
||||
{{ inventory_hostname }} to pick up the dkms-built DRBD 9.
|
||||
when:
|
||||
- "'version: 9' not in _drbd_ver.stdout"
|
||||
- (_drbd_refs.stdout | default('0') | trim | int) > 0
|
||||
|
||||
- name: Swap the in-tree DRBD 8.4 for the dkms-built DRBD 9
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
modprobe -r drbd_transport_tcp 2>/dev/null || true
|
||||
modprobe -r drbd
|
||||
depmod -a
|
||||
modprobe drbd
|
||||
when:
|
||||
- "'version: 9' not in _drbd_ver.stdout"
|
||||
- (_drbd_refs.stdout | default('0') | trim | int) == 0
|
||||
|
||||
- name: Re-read the DRBD version after the swap
|
||||
ansible.builtin.shell:
|
||||
cmd: "cat /proc/drbd 2>/dev/null | head -1"
|
||||
register: _drbd_ver
|
||||
changed_when: false
|
||||
|
||||
- name: Assert DRBD 9
|
||||
ansible.builtin.assert:
|
||||
that: "'version: 9' in _drbd_ver.stdout"
|
||||
fail_msg: "Expected DRBD 9, got: {{ _drbd_ver.stdout }}"
|
||||
quiet: true
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
# LVM volume groups that back the LINSTOR pools.
|
||||
#
|
||||
# DESTRUCTIVE. Everything that destroys data is gated behind
|
||||
# pve_linstor_wipe_hdd and guarded by an explicit "is this really free?" check.
|
||||
|
||||
# ── SSD pool: the space the installer left beyond the 60G `pve` VG ─────────
|
||||
- name: Find the SSD holding the pve VG
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
pvs --noheadings -o pv_name --select 'vg_name=pve' | tr -d ' ' | head -1
|
||||
executable: /bin/bash
|
||||
register: _pve_pv
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Derive the parent disk holding the pve PV
|
||||
# DO NOT use `lsblk -no PKNAME <pv>`: without --nodeps lsblk also lists the LVs
|
||||
# stacked on the partition, whose PKNAME is the PARTITION itself, so `head -1`
|
||||
# can return e.g. "nvme0n1p3" instead of "nvme0n1". That mistake made sgdisk
|
||||
# write a GPT INTO the LVM PV holding root on pve2/pve3 and destroyed their
|
||||
# `pve` VG metadata (2026-07-25). sysfs is unambiguous: the parent of
|
||||
# /sys/class/block/<part> IS the disk.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
PV="{{ _pve_pv.stdout | trim }}"
|
||||
PART="$(basename "$PV")"
|
||||
DISK="$(basename "$(readlink -f "/sys/class/block/${PART}/..")")"
|
||||
echo "/dev/${DISK}"
|
||||
executable: /bin/bash
|
||||
register: _ssd_disk
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Refuse to proceed unless that really is a whole disk
|
||||
# Last line of defence: sgdisk against a partition is destructive, so verify
|
||||
# the derived device is TYPE=disk and not a partition before touching it.
|
||||
ansible.builtin.shell:
|
||||
cmd: "lsblk -dno TYPE {{ _ssd_disk.stdout | trim }}"
|
||||
register: _ssd_type
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Assert it is a disk
|
||||
ansible.builtin.assert:
|
||||
that: "(_ssd_type.stdout | trim) == 'disk'"
|
||||
fail_msg: >-
|
||||
Derived SSD device {{ _ssd_disk.stdout | trim }} is TYPE
|
||||
'{{ _ssd_type.stdout | trim }}', not 'disk'. Refusing to partition it —
|
||||
running sgdisk against a partition destroys whatever is on it.
|
||||
quiet: true
|
||||
|
||||
- name: Check whether the SSD VG already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "vgs {{ pve_linstor_ssd_vg }}"
|
||||
register: _ssd_vg
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create a partition from the SSD's free tail
|
||||
# sgdisk -N uses ALL remaining free space for the next partition number.
|
||||
# Only touches unallocated space; the existing pve partitions are untouched.
|
||||
ansible.builtin.command:
|
||||
cmd: "sgdisk -N 0 -t 0:8e00 -c 0:linstor-ssd {{ _ssd_disk.stdout | trim }}"
|
||||
when: _ssd_vg.rc != 0
|
||||
register: _ssd_part
|
||||
|
||||
- name: Re-read the partition table
|
||||
# partx (util-linux) rather than partprobe: `parted` is NOT installed on a
|
||||
# stock PVE 9 node, so partprobe fails with "No such file or directory".
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
partx -u "{{ _ssd_disk.stdout | trim }}"
|
||||
udevadm settle
|
||||
executable: /bin/bash
|
||||
when: _ssd_part is changed
|
||||
changed_when: true
|
||||
|
||||
- name: Find the new SSD partition
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
lsblk -rno NAME,PARTLABEL "{{ _ssd_disk.stdout | trim }}" \
|
||||
| awk '$2=="linstor-ssd" {print "/dev/"$1}' | head -1
|
||||
executable: /bin/bash
|
||||
register: _ssd_partdev
|
||||
changed_when: false
|
||||
|
||||
- name: Create the SSD volume group
|
||||
ansible.builtin.command:
|
||||
cmd: "vgcreate {{ pve_linstor_ssd_vg }} {{ _ssd_partdev.stdout | trim }}"
|
||||
when:
|
||||
- _ssd_vg.rc != 0
|
||||
- (_ssd_partdev.stdout | trim) | length > 0
|
||||
|
||||
# ── HDD pool: the whole 1TB spindle ───────────────────────────────────────
|
||||
- name: Confirm the HDD target is a whole disk
|
||||
# Same guard as the SSD path. wipefs/vgcreate against a partition by mistake is
|
||||
# how the pve VG on pve2/pve3 got destroyed on 2026-07-25; assert the device
|
||||
# type rather than trusting the variable.
|
||||
ansible.builtin.shell:
|
||||
cmd: "lsblk -dno TYPE {{ pve_linstor_hdd_disk }}"
|
||||
register: _hdd_type
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
|
||||
- name: Assert the HDD target is a disk
|
||||
ansible.builtin.assert:
|
||||
that: "(_hdd_type.stdout | trim) == 'disk'"
|
||||
fail_msg: >-
|
||||
{{ pve_linstor_hdd_disk }} is TYPE '{{ _hdd_type.stdout | trim }}', not
|
||||
'disk'. Refusing to wipe it.
|
||||
quiet: true
|
||||
|
||||
- name: Inspect the HDD's current volume group
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
pvs --noheadings -o vg_name {{ pve_linstor_hdd_disk }} 2>/dev/null | tr -d ' '
|
||||
executable: /bin/bash
|
||||
register: _hdd_vg
|
||||
changed_when: false
|
||||
check_mode: false # read-only discovery; must run in --check
|
||||
failed_when: false
|
||||
|
||||
- name: Refuse to wipe a HDD carrying anything other than a stale Ceph VG
|
||||
# The only VGs we expect here are the leftover ceph-<uuid> ones from the old
|
||||
# cluster. Anything else means this disk is not what we think it is.
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
{{ pve_linstor_hdd_disk }} on {{ inventory_hostname }} holds VG
|
||||
'{{ _hdd_vg.stdout | trim }}', which is not a stale ceph-* VG.
|
||||
Refusing to wipe. Inspect it by hand.
|
||||
when:
|
||||
- (_hdd_vg.stdout | trim) | length > 0
|
||||
- not (_hdd_vg.stdout | trim).startswith('ceph-')
|
||||
- (_hdd_vg.stdout | trim) != pve_linstor_hdd_vg
|
||||
|
||||
- name: Remove the stale Ceph volume group
|
||||
ansible.builtin.command:
|
||||
cmd: "vgremove -f {{ _hdd_vg.stdout | trim }}"
|
||||
when:
|
||||
- pve_linstor_wipe_hdd | bool
|
||||
- (_hdd_vg.stdout | trim).startswith('ceph-')
|
||||
|
||||
- name: Wipe the HDD's signatures
|
||||
ansible.builtin.command:
|
||||
cmd: "wipefs -a {{ pve_linstor_hdd_disk }}"
|
||||
when:
|
||||
- pve_linstor_wipe_hdd | bool
|
||||
- (_hdd_vg.stdout | trim).startswith('ceph-')
|
||||
|
||||
- name: Check whether the HDD VG already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "vgs {{ pve_linstor_hdd_vg }}"
|
||||
register: _hdd_vg_now
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create the HDD volume group
|
||||
ansible.builtin.command:
|
||||
cmd: "vgcreate {{ pve_linstor_hdd_vg }} {{ pve_linstor_hdd_disk }}"
|
||||
when: _hdd_vg_now.rc != 0
|
||||
|
||||
- name: Report the resulting volume groups
|
||||
ansible.builtin.command:
|
||||
cmd: "vgs --noheadings -o vg_name,vg_size,vg_free"
|
||||
register: _vgs
|
||||
changed_when: false
|
||||
|
||||
- name: Show them
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _vgs.stdout_lines }}"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Reload postfix
|
||||
ansible.builtin.systemd_service:
|
||||
name: postfix
|
||||
state: reloaded
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# Route all node mail (PVE alerts, smartd, cron) through the k3s smtp-relay on
|
||||
# the laptop, which authenticates to M365 with OAuth2/XOAUTH2.
|
||||
# See services/smtp-relay/ for the relay side.
|
||||
#
|
||||
# Applied by hand on 2026-07-25; codified here so a rebuild does not lose it.
|
||||
#
|
||||
# Each setting is READ first and only written when it differs, so re-runs report
|
||||
# no change. `check_mode: false` on the reads lets --check make a real
|
||||
# comparison instead of skipping (postconf reads nothing but config).
|
||||
|
||||
- name: Read current postfix settings
|
||||
ansible.builtin.command:
|
||||
cmd: postconf -h relayhost sender_canonical_classes sender_canonical_maps
|
||||
register: _pf
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Point postfix at the smtp-relay
|
||||
# [brackets] mean "this is the literal host", suppressing the MX lookup that
|
||||
# would otherwise be attempted for a bare address.
|
||||
ansible.builtin.command:
|
||||
cmd: postconf -e "relayhost = {{ pve_mail_relayhost }}"
|
||||
when: (_pf.stdout_lines[0] | default('') | trim) != pve_mail_relayhost
|
||||
notify: Reload postfix
|
||||
|
||||
- name: Install the sender rewrite map
|
||||
# M365 authenticates as {{ pve_mail_from }} and rejects any other From with
|
||||
# 5.7.60 SendAsDenied. Node mail is generated as root@<fqdn>, so every local
|
||||
# sender must be rewritten. In-cluster apps (Authelia, Gitea) never hit this
|
||||
# because they already send as noreply@.
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/postfix/sender_canonical
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by Ansible (role pve_mail_relay).
|
||||
/.+/ {{ pve_mail_from }}
|
||||
notify: Reload postfix
|
||||
|
||||
- name: Enable sender rewriting for the envelope
|
||||
ansible.builtin.command:
|
||||
cmd: postconf -e "sender_canonical_classes = envelope_sender, header_sender"
|
||||
when: (_pf.stdout_lines[1] | default('') | trim) != 'envelope_sender, header_sender'
|
||||
notify: Reload postfix
|
||||
|
||||
- name: Point postfix at the sender rewrite map
|
||||
ansible.builtin.command:
|
||||
cmd: postconf -e "sender_canonical_maps = regexp:/etc/postfix/sender_canonical"
|
||||
when: (_pf.stdout_lines[2] | default('') | trim) != 'regexp:/etc/postfix/sender_canonical'
|
||||
notify: Reload postfix
|
||||
|
||||
- name: Ensure postfix is enabled and running
|
||||
ansible.builtin.systemd_service:
|
||||
name: postfix
|
||||
state: started
|
||||
enabled: true
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# Jumbo frames on the LAN.
|
||||
#
|
||||
# VERIFIED 2026-07-25: the unmanaged switch passes 8972-byte payloads with DF set
|
||||
# between all three nodes, so jumbo works despite there being no managed switch.
|
||||
#
|
||||
# WHY: (1) DRBD/LINSTOR replicates every write across this single 1G link —
|
||||
# larger frames mean fewer interrupts and better throughput on the storage path.
|
||||
# (2) It gives VXLAN room for its ~50-byte overhead, so SDN guests keep a normal
|
||||
# 1500 MTU instead of being cut to 1450 (retro OSes handle PMTUD badly).
|
||||
#
|
||||
# SAFETY on a mixed-MTU segment (router/DC/laptop remain 1500): TCP exchanges MSS
|
||||
# in the SYN, so each side sends no more than the other advertised — verified,
|
||||
# 1472B pings to all three still pass. The residual risk is only large UDP to a
|
||||
# 1500-MTU host; there is none here (corosync uses its own netmtu 1500, DNS is
|
||||
# small, NFS is TCP).
|
||||
pve_network_mtu: 9000
|
||||
|
||||
# Make vmbr0 VLAN-aware so PVE SDN "vlan" zones can hang tagged VNets off it.
|
||||
#
|
||||
# VERIFIED on this LAN 2026-07-25: the unmanaged switch forwards 802.1Q-tagged
|
||||
# frames untouched (tested VLAN 100 between nodes at both 1472B and 8972B). Dumb
|
||||
# switches forward on MAC only — the tag is opaque payload to them.
|
||||
#
|
||||
# So VLAN zones beat VXLAN here: native forwarding, no encapsulation overhead, no
|
||||
# MTU maths. Trade-off: a dumb switch enforces no isolation, so VLANs give
|
||||
# SEGMENTATION, not security — anything on the LAN could inject tagged frames.
|
||||
# Fine for lab networks; do not treat a VLAN here as a security boundary.
|
||||
pve_network_vlan_aware: true
|
||||
pve_network_bridge_vids: "2-4094"
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
- name: Find the bridge port behind vmbr0
|
||||
# Differs per node (pve1 eno1, pve2/pve3 enp1s0f0), so detect rather than hardcode.
|
||||
ansible.builtin.shell:
|
||||
cmd: "ip -br link show master vmbr0 | awk '{print $1}' | head -1"
|
||||
register: _phys
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Set MTU persistently on the physical port
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/network/interfaces
|
||||
insertafter: "^iface {{ _phys.stdout | trim }} inet manual"
|
||||
line: "\tmtu {{ pve_network_mtu }}"
|
||||
regexp: "^\tmtu "
|
||||
firstmatch: true
|
||||
backup: true
|
||||
register: _phys_mtu
|
||||
|
||||
- name: Set MTU persistently on vmbr0
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/network/interfaces
|
||||
insertafter: "^\tbridge-fd 0"
|
||||
marker: "#{mark} ANSIBLE pve_network mtu"
|
||||
block: "\tmtu {{ pve_network_mtu }}"
|
||||
register: _br_mtu
|
||||
|
||||
- name: Make vmbr0 VLAN-aware
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/network/interfaces
|
||||
insertafter: "^\tbridge-fd 0"
|
||||
marker: "#{mark} ANSIBLE pve_network vlan-aware"
|
||||
# Double-quoted so \t is a YAML ESCAPE: a literal tab cannot be used
|
||||
# for indentation inside a block scalar -- YAML rejects tabs outright.
|
||||
block: "\tbridge-vlan-aware yes\n\tbridge-vids {{ pve_network_bridge_vids }}"
|
||||
when: pve_network_vlan_aware | bool
|
||||
register: _vlan_aware
|
||||
|
||||
- name: Reload networking if the bridge definition changed
|
||||
# ifreload applies in place; it does NOT drop the management IP for a simple
|
||||
# vlan-aware flip, but this is still done serially (site.yml runs serial:1) so
|
||||
# a mistake cannot take all three nodes at once.
|
||||
ansible.builtin.command:
|
||||
cmd: ifreload -a
|
||||
when: _vlan_aware is changed
|
||||
changed_when: true
|
||||
|
||||
- name: Apply at runtime too (no reboot needed)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
ip link set {{ _phys.stdout | trim }} mtu {{ pve_network_mtu }}
|
||||
ip link set vmbr0 mtu {{ pve_network_mtu }}
|
||||
when: _phys_mtu is changed or _br_mtu is changed
|
||||
changed_when: true
|
||||
|
||||
- name: Verify the MTU is live
|
||||
ansible.builtin.shell:
|
||||
cmd: "ip link show vmbr0 | grep -o 'mtu [0-9]*'"
|
||||
register: _mtu_now
|
||||
changed_when: false
|
||||
|
||||
- name: Assert it took
|
||||
ansible.builtin.assert:
|
||||
that: "pve_network_mtu | string in _mtu_now.stdout"
|
||||
fail_msg: "vmbr0 MTU is {{ _mtu_now.stdout }}, expected {{ pve_network_mtu }}"
|
||||
quiet: true
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# NFS storage from the laptop ("core" node, 192.168.10.127), which is
|
||||
# deliberately NOT a cluster member — it holds the ZFS pool, serves NFS, and runs
|
||||
# netboot.xyz + k3s. See the project memory.
|
||||
#
|
||||
# This carries ISOs, container templates, backups and snippets. VM/CT DISKS live
|
||||
# on LINSTOR (pve-rg / pve-rg-hdd), NOT here: an NFS-backed disk would make every
|
||||
# guest depend on the laptop being up, which is exactly the coupling the
|
||||
# hyperconverged storage exists to avoid.
|
||||
pve_nfs_storage_id: laptop
|
||||
pve_nfs_server: 192.168.10.127
|
||||
pve_nfs_export: /mnt/pool/proxmox
|
||||
# Matches the directories that already exist in the export (dump/template/
|
||||
# snippets/import). No `images`/`rootdir` — see above.
|
||||
pve_nfs_content: "backup,iso,vztmpl,snippets,import"
|
||||
# The export is published by ZFS `sharenfs` (see /etc/exports.d/zfs.exports on
|
||||
# the laptop), not /etc/exports — change it with `zfs set sharenfs=...`.
|
||||
pve_nfs_options: ""
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
# Storage config lives in /etc/pve/storage.cfg, which is replicated cluster-wide,
|
||||
# so this runs once against a single node.
|
||||
|
||||
- name: Check whether the storage is already defined
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesm status --storage {{ pve_nfs_storage_id }}"
|
||||
register: _nfs_exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Add the NFS storage
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesm add nfs {{ pve_nfs_storage_id }}
|
||||
--server {{ pve_nfs_server }}
|
||||
--export {{ pve_nfs_export }}
|
||||
--content {{ pve_nfs_content }}
|
||||
{% if pve_nfs_options %}--options {{ pve_nfs_options }}{% endif %}
|
||||
when: _nfs_exists.rc != 0
|
||||
run_once: true
|
||||
|
||||
- name: Wait for it to come active
|
||||
# NFS storage is only usable once every node has mounted it; PVE mounts lazily.
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesm status --storage {{ pve_nfs_storage_id }}"
|
||||
register: _nfs_status
|
||||
until: "'active' in (_nfs_status.stdout | default(''))"
|
||||
retries: 12
|
||||
delay: 5
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Verify every node can actually see it
|
||||
# A cluster-wide storage entry that only ONE node can mount is a silent trap:
|
||||
# migrations and backups fail on the others. Check per-node, not just once.
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesm status --storage {{ pve_nfs_storage_id }}"
|
||||
register: _nfs_node
|
||||
changed_when: false
|
||||
failed_when: "'active' not in (_nfs_node.stdout | default(''))"
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ inventory_hostname }}: {{ _nfs_node.stdout_lines | select('search', pve_nfs_storage_id) | list }}"
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
# Path to the web-UI JS carrying the subscription check. Kept here (not inline)
|
||||
# because it appears in both the patch task and the apt hook.
|
||||
_pve_nag_file: /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
- name: Refresh apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
register: _h_apt
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _h_apt is succeeded
|
||||
|
||||
- name: Restart pveproxy
|
||||
# Serves the web UI; picks up the patched proxmoxlib.js. Does NOT interrupt
|
||||
# running guests.
|
||||
ansible.builtin.systemd_service:
|
||||
name: pveproxy
|
||||
state: restarted
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
# Declarative equivalent of the community "post-pve-install.sh" script.
|
||||
# Written as a role rather than piping curl into bash as root, so it is
|
||||
# idempotent, reviewable, and survives a node rebuild.
|
||||
|
||||
- name: Confirm we are actually on a Proxmox node
|
||||
ansible.builtin.stat:
|
||||
path: /usr/bin/pveversion
|
||||
register: _pveversion
|
||||
changed_when: false
|
||||
|
||||
- name: Fail early on a non-PVE host
|
||||
ansible.builtin.fail:
|
||||
msg: "{{ inventory_hostname }} has no /usr/bin/pveversion -- this role only targets PVE nodes."
|
||||
when: not _pveversion.stat.exists
|
||||
|
||||
- name: Force apt to use IPv4
|
||||
# MUST come before any apt operation. These nodes have NO IPv6 egress (no
|
||||
# global v6 address, no default v6 route) but DNS still returns AAAA records,
|
||||
# so apt tries a dead IPv6 path first and stalls per-mirror. This is what hung
|
||||
# `apt update` against packages.linbit.com (resolved to 2a01:4f8:1c1c:6ab9::1).
|
||||
# Complements the gai.conf precedence set by pve_dns.
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/apt.conf.d/99force-ipv4
|
||||
mode: "0644"
|
||||
content: |
|
||||
// Managed by Ansible (role pve_post_install). Nodes have no IPv6 egress.
|
||||
Acquire::ForceIPv4 "true";
|
||||
|
||||
# ── repositories ────────────────────────────────────────────────────────
|
||||
# PVE 9 / Debian 13 uses deb822 (.sources) files, NOT the old one-line .list
|
||||
# format, so these use deb822_repository rather than apt_repository.
|
||||
#
|
||||
# NOTE the FQCN: deb822_repository has been promoted INTO ansible-core, so it is
|
||||
# `ansible.builtin.deb822_repository`. It is NOT in community.general (which
|
||||
# ships only apt_repo, for openSUSE) -- reaching for the community.general name
|
||||
# fails with "couldn't resolve module/action".
|
||||
|
||||
- name: Disable the pve-enterprise repository
|
||||
# 401s on every apt update without a subscription.
|
||||
ansible.builtin.deb822_repository:
|
||||
name: pve-enterprise
|
||||
types: [deb]
|
||||
uris: https://enterprise.proxmox.com/debian/pve
|
||||
suites: ["{{ ansible_facts['distribution_release'] | default(pve_suite) }}"]
|
||||
components: [pve-enterprise]
|
||||
signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
enabled: false
|
||||
state: present
|
||||
when: pve_disable_enterprise_repo
|
||||
notify: Refresh apt cache
|
||||
|
||||
- name: Disable the enterprise Ceph repository
|
||||
# This cluster uses LINSTOR/DRBD, not Ceph -- Ceph on HDD OSDs was tried and
|
||||
# was far too slow. Leaving this enabled only produces 401s.
|
||||
ansible.builtin.deb822_repository:
|
||||
name: ceph
|
||||
types: [deb]
|
||||
uris: https://enterprise.proxmox.com/debian/ceph-squid
|
||||
suites: ["{{ ansible_facts['distribution_release'] | default(pve_suite) }}"]
|
||||
components: [enterprise]
|
||||
signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
enabled: false
|
||||
state: present
|
||||
when: pve_disable_ceph_repo
|
||||
notify: Refresh apt cache
|
||||
|
||||
- name: Enable the pve-no-subscription repository
|
||||
ansible.builtin.deb822_repository:
|
||||
name: pve-no-subscription
|
||||
types: [deb]
|
||||
uris: http://download.proxmox.com/debian/pve
|
||||
suites: ["{{ ansible_facts['distribution_release'] | default(pve_suite) }}"]
|
||||
components: [pve-no-subscription]
|
||||
signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
enabled: true
|
||||
state: present
|
||||
when: pve_enable_no_subscription_repo
|
||||
notify: Refresh apt cache
|
||||
|
||||
- name: Apply repository changes now
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
# ── subscription nag ────────────────────────────────────────────────────
|
||||
# The web UI shows a "No valid subscription" modal on every login. The check is
|
||||
# a single JS expression; forcing it false removes the dialog.
|
||||
|
||||
- name: Back up proxmoxlib.js before patching
|
||||
ansible.builtin.copy:
|
||||
src: "{{ _pve_nag_file }}"
|
||||
dest: "{{ _pve_nag_file }}.orig"
|
||||
remote_src: true
|
||||
force: false # never clobber an existing pristine backup
|
||||
mode: "0644"
|
||||
when: pve_remove_subscription_nag
|
||||
|
||||
- name: Remove the subscription nag dialog
|
||||
# In PVE 9 the expression is `res.data.status.toLowerCase() !== 'active'`.
|
||||
# The widely-copied sed matches only `data.status...` and so leaves `res.false`
|
||||
# behind -- broken-looking JS that happens to work only because `res.false`
|
||||
# evaluates to undefined (falsy). Match the optional `res.` prefix too so the
|
||||
# result is a clean `false` instead of relying on that accident.
|
||||
ansible.builtin.replace:
|
||||
path: "{{ _pve_nag_file }}"
|
||||
regexp: "(?:res\\.)?data\\.status\\.toLowerCase\\(\\) !== 'active'"
|
||||
replace: "false"
|
||||
when: pve_remove_subscription_nag
|
||||
notify: Restart pveproxy
|
||||
|
||||
- name: Install the nag re-patch helper script
|
||||
# The sed lives in a script, NOT inlined in the apt hook: apt.conf's own
|
||||
# quoting cannot carry a regex full of backslashes and quotes, and an invalid
|
||||
# file there breaks EVERY apt invocation ("Extra junk after value") -- which
|
||||
# is a much worse failure than the nag itself.
|
||||
ansible.builtin.copy:
|
||||
dest: /usr/local/sbin/pve-remove-nag.sh
|
||||
mode: "0755"
|
||||
content: |
|
||||
#!/bin/sh
|
||||
# Managed by Ansible (services/proxmox/ansible, role pve_post_install).
|
||||
# Re-strips the "No valid subscription" dialog, which a
|
||||
# proxmox-widget-toolkit upgrade restores.
|
||||
# The (res\.)? prefix matters: without it this leaves `res.false`.
|
||||
F={{ _pve_nag_file }}
|
||||
[ -s "$F" ] || exit 0
|
||||
sed -i -E "s/(res\.)?data\.status\.toLowerCase\(\) !== 'active'/false/g" "$F"
|
||||
exit 0
|
||||
when: pve_remove_subscription_nag
|
||||
|
||||
- name: Re-apply the nag patch after any proxmox-widget-toolkit upgrade
|
||||
# An apt upgrade reinstates the original file, so without this hook the nag
|
||||
# silently returns. This is what makes the change durable rather than one-shot.
|
||||
# `|| true` so a failure here can never block a package operation.
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/apt.conf.d/no-nag-script
|
||||
mode: "0644"
|
||||
content: |
|
||||
// Managed by Ansible (services/proxmox/ansible, role pve_post_install).
|
||||
DPkg::Post-Invoke { "/usr/local/sbin/pve-remove-nag.sh || true"; };
|
||||
when: pve_remove_subscription_nag
|
||||
|
||||
# ── updates ─────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Update the apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
cache_valid_time: 0
|
||||
register: _apt_update
|
||||
# The home uplink drops out regularly (observed mid-session), so a single
|
||||
# transient failure must not abort the play.
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _apt_update is succeeded
|
||||
|
||||
- name: Perform a full dist-upgrade
|
||||
ansible.builtin.apt:
|
||||
upgrade: dist
|
||||
autoremove: true
|
||||
register: _apt_upgrade
|
||||
retries: 2
|
||||
delay: 30
|
||||
until: _apt_upgrade is succeeded
|
||||
when: pve_dist_upgrade
|
||||
|
||||
- name: Report whether a reboot is required
|
||||
ansible.builtin.stat:
|
||||
path: /var/run/reboot-required
|
||||
register: _reboot_required
|
||||
changed_when: false
|
||||
|
||||
- name: Show reboot notice
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ inventory_hostname }} requires a reboot (kernel or core library updated)."
|
||||
when: _reboot_required.stat.exists
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
# PVE SDN — VLAN zone on vmbr0.
|
||||
#
|
||||
# WHY VLAN AND NOT VXLAN: the unmanaged switch forwards 802.1Q-tagged frames
|
||||
# untouched (dumb switches forward on MAC; the tag is opaque payload). VERIFIED
|
||||
# on this LAN 2026-07-25 — a VLAN-100 ping between nodes passed at both 1472B and
|
||||
# 8972B. So VLAN gives cross-node L2 with NO encapsulation, no overhead, and no
|
||||
# MTU arithmetic. VXLAN would work too (jumbo leaves room for its ~50 bytes) but
|
||||
# buys nothing here and costs CPU.
|
||||
#
|
||||
# ⚠️ A dumb switch enforces nothing, so these VLANs are SEGMENTATION, not
|
||||
# security: anything on the LAN could inject tagged frames. Do not treat a VNet
|
||||
# here as an isolation boundary for anything that matters.
|
||||
#
|
||||
# NOTE: a VLAN zone is pure L2 — no gateway, no SNAT, no internet. That is the
|
||||
# point for lab/retro-OS networks. If a VNet later needs routing or internet
|
||||
# egress, that is an EVPN zone (frr is already installed) or a router VM.
|
||||
pve_sdn_zone: lab
|
||||
pve_sdn_bridge: vmbr0
|
||||
# Guests keep a standard 1500 MTU; the 9000 underlay carries it comfortably.
|
||||
pve_sdn_mtu: 1500
|
||||
|
||||
pve_sdn_vnets:
|
||||
- name: labnet # general isolated lab L2
|
||||
tag: 100
|
||||
alias: "Isolated lab network (no gateway)"
|
||||
- name: retronet # retro OSes: keep ancient stacks off the real LAN
|
||||
tag: 110
|
||||
alias: "Retro OS network (no gateway, no internet)"
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
# SDN config is cluster-wide (/etc/pve/sdn/), so everything runs once.
|
||||
|
||||
- name: Read existing zones
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh get /cluster/sdn/zones --output-format json"
|
||||
register: _zones
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Create the VLAN zone
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesh create /cluster/sdn/zones --zone {{ pve_sdn_zone }} --type vlan
|
||||
--bridge {{ pve_sdn_bridge }} --mtu {{ pve_sdn_mtu }}
|
||||
--nodes {{ groups['pve'] | join(',') }}
|
||||
when: pve_sdn_zone not in (_zones.stdout | from_json | map(attribute='zone') | list)
|
||||
run_once: true
|
||||
|
||||
- name: Read existing vnets
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh get /cluster/sdn/vnets --output-format json"
|
||||
register: _vnets
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Create the VNets
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesh create /cluster/sdn/vnets --vnet {{ item.name }}
|
||||
--zone {{ pve_sdn_zone }} --tag {{ item.tag }}
|
||||
--alias '{{ item.alias }}'
|
||||
loop: "{{ pve_sdn_vnets }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }} (vlan {{ item.tag }})"
|
||||
when: item.name not in (_vnets.stdout | from_json | map(attribute='vnet') | list)
|
||||
run_once: true
|
||||
|
||||
- name: Apply the SDN configuration
|
||||
# SDN changes stay PENDING until applied; without this the VNet bridges are
|
||||
# never actually created on the nodes.
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh set /cluster/sdn"
|
||||
register: _apply
|
||||
changed_when: true
|
||||
run_once: true
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.shell:
|
||||
cmd: "pvesh get /cluster/sdn/vnets --output-format json | python3 -c \"import json,sys;[print(' ',v['vnet'],'vlan',v.get('tag'),'zone',v.get('zone')) for v in json.load(sys.stdin)]\""
|
||||
register: _rep
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Show it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _rep.stdout_lines }}"
|
||||
run_once: true
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
# Declarative-ish VM definitions. Uses `qm` over SSH rather than
|
||||
# community.general.proxmox_kvm: that module needs `proxmoxer` plus API-token
|
||||
# auth, while every other role here already drives pvesh/qm on the node. Staying
|
||||
# consistent beats adding a dependency for one role.
|
||||
#
|
||||
# Creation is guarded by `qm status <vmid>`, so re-runs never touch an existing
|
||||
# VM. Changing a definition here does NOT retro-fit a live VM — adjust it with
|
||||
# `qm set` deliberately, or destroy and recreate.
|
||||
|
||||
pve_vm_node: pve1 # where new VMs are created
|
||||
|
||||
# Anything a VM needs to be MANAGEABLE, installed by cloud-init on first boot —
|
||||
# before Ansible ever connects. qemu-guest-agent especially: without it PVE
|
||||
# cannot see guest IPs, do a clean shutdown, or freeze the fs for snapshots.
|
||||
pve_vm_base_packages:
|
||||
- qemu-guest-agent
|
||||
- openssh-server
|
||||
- python3 # Ansible needs an interpreter
|
||||
pve_vm_ssh_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
|
||||
# PVE storage with `snippets` content enabled (see role pve_nfs).
|
||||
pve_vm_snippet_storage: laptop
|
||||
pve_vm_snippet_path: /mnt/pve/laptop/snippets
|
||||
|
||||
pve_vms:
|
||||
- vmid: 100
|
||||
name: vyos-rtr
|
||||
description: "VyOS router: OSPF peer to the NEC IX, gateway for the SDN VNets."
|
||||
cores: 2
|
||||
memory: 2048
|
||||
disk: "pve-rg:8"
|
||||
# Headless by design: UEFI + serial console, NO emulated VGA. VyOS's own
|
||||
# kernel cmdline carries console=ttyS0, and the installer was answered with
|
||||
# console=Serial, so `qm terminal 100` stays available out-of-band.
|
||||
bios: ovmf
|
||||
machine: q35
|
||||
vga: serial0
|
||||
serial: true
|
||||
nets:
|
||||
- { id: 0, bridge: vmbr0 } # LAN / OSPF
|
||||
- { id: 1, bridge: labnet }
|
||||
- { id: 2, bridge: retronet }
|
||||
onboot: 1
|
||||
iso: "laptop:iso/vyos-2025.11-generic-amd64.iso"
|
||||
|
||||
- vmid: 101
|
||||
name: retrolab
|
||||
description: "86Box host. RDP in when you want it. net1 = retronet (no IP), for the emulated NICs."
|
||||
# ⚠️ BELONGS ON pve2/pve3, NOT on the pve1 that `pve_vm_node` creates it on.
|
||||
# 86Box is a single-threaded recompiler and pve1 is an i3-6100U (2c/4t,
|
||||
# 2.3 GHz) that also carries vyos-rtr — emulation stuttered and the emulated
|
||||
# Sound Blaster glitched. The Ryzen 5 PRO 2400GE nodes (4c/8t, 3.2 GHz) do
|
||||
# not. Moved to pve3 on 2026-07-28. If this VM is ever recreated here,
|
||||
# migrate it back off pve1 afterwards; `cpu: host` (kept, it is most of the
|
||||
# win) makes that an OFFLINE migration, because pve1 is Intel and pve2/pve3
|
||||
# are AMD.
|
||||
cores: 4
|
||||
memory: 4096
|
||||
disk: "pve-rg:40"
|
||||
bios: ovmf
|
||||
machine: q35
|
||||
vga: virtio # a desktop, unlike the router
|
||||
serial: true
|
||||
# MACs are PINNED. Destroying and recreating a VM otherwise generates new
|
||||
# ones, so its DHCP reservation stops matching and its address moves -- which
|
||||
# is exactly what happened on the first rebuild.
|
||||
nets:
|
||||
- { id: 0, bridge: labnet, mac: "BC:24:11:68:A0:51" } # DHCP-reserved to 10.60.0.10
|
||||
- { id: 1, bridge: retronet, mac: "BC:24:11:9F:63:ED" } # no IP - handed to 86Box
|
||||
onboot: 0
|
||||
cloudinit: true
|
||||
ciuser: panxiao81
|
||||
# ⚠️ Do NOT use `qm importdisk` for this image. It reported success
|
||||
# ("transferred 3.5 GiB", exit 0) but left ZEROS where the partition table
|
||||
# belongs, producing a silently unbootable disk. Write it straight to the
|
||||
# DRBD device instead (see tasks/image.yml).
|
||||
image: "/mnt/pve/laptop/import/noble-server-cloudimg-amd64.img.raw"
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
# Write a base image onto a freshly created VM disk.
|
||||
#
|
||||
# ⚠️ Deliberately NOT `qm importdisk`. On this LINSTOR/DRBD storage it reported
|
||||
# success ("transferred 3.5 GiB", exit 0) but left ZEROS where the partition
|
||||
# table belongs, producing a silently unbootable disk. `qemu-img convert`
|
||||
# straight to the DRBD device works and takes ~40s. (Verified 2026-07-25.)
|
||||
#
|
||||
# Also note: the source is named *.img.raw but is actually QCOW2 — always let
|
||||
# qemu detect the format rather than trusting the extension.
|
||||
|
||||
- name: Resolve the DRBD device backing the VM disk
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -o pipefail
|
||||
# scsi0: <storage>:<volume>,opts... -> we want <volume>, i.e. field 3
|
||||
vol=$(qm config {{ item.vmid }} | sed -n 's/^scsi0: *[^:]*:\([^,]*\).*/\1/p')
|
||||
# PVE's volume is pm-xxxx_<vmid>, but the LINSTOR RESOURCE is just
|
||||
# pm-xxxx -- strip the _<vmid> suffix or nothing ever matches.
|
||||
res=${vol%_*}
|
||||
linstor resource list-volumes 2>/dev/null \
|
||||
| awk -v v="$res" -v n="$(hostname)" '$0 ~ v && $0 ~ n {print}' \
|
||||
| grep -oE '/dev/drbd[0-9]+' | head -1
|
||||
executable: /bin/bash
|
||||
register: _drbd_dev
|
||||
changed_when: false
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Fail if the device could not be resolved
|
||||
ansible.builtin.fail:
|
||||
msg: "Could not find the DRBD device for VM {{ item.vmid }} scsi0 — refusing to write an image blind."
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
when: (_drbd_dev.stdout | trim) | length == 0
|
||||
|
||||
- name: Check whether the disk already has a partition table
|
||||
# Idempotency guard: never overwrite a disk that already looks installed.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
python3 -c "
|
||||
import sys
|
||||
try:
|
||||
d=open('{{ _drbd_dev.stdout | trim }}','rb').read(512)
|
||||
sys.stdout.write('yes' if d[510:512]==b'\x55\xaa' else 'no')
|
||||
except Exception:
|
||||
sys.stdout.write('unreadable')"
|
||||
executable: /bin/bash
|
||||
register: _has_mbr
|
||||
changed_when: false
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Write the base image
|
||||
# DRBD must be Primary to accept writes; reading a Secondary returns ZEROS,
|
||||
# which is its own excellent way to misdiagnose an empty disk.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
drbdadm primary {{ _res }} 2>/dev/null || true
|
||||
qemu-img convert -O raw "{{ item.image }}" "{{ _dev }}"
|
||||
sync
|
||||
drbdadm secondary {{ _res }} 2>/dev/null || true
|
||||
executable: /bin/bash
|
||||
vars:
|
||||
_dev: "{{ _drbd_dev.stdout | trim }}"
|
||||
_res: "{{ _drbd_dev.stdout | trim | regex_replace('.*drbd', '') }}"
|
||||
when: _has_mbr.stdout is not search('yes')
|
||||
changed_when: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
# Create VMs that do not exist yet. Idempotent by existence check only — this
|
||||
# role does NOT reconcile the config of a live VM (see defaults for why).
|
||||
|
||||
- name: Check which VMs already exist
|
||||
ansible.builtin.command:
|
||||
cmd: "qm status {{ item.vmid }}"
|
||||
loop: "{{ pve_vms }}"
|
||||
loop_control:
|
||||
label: "{{ item.vmid }} {{ item.name }}"
|
||||
register: _vm_exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Render cloud-init user-data for VMs that use it
|
||||
# Written to the NFS `snippets` dir so every node can read it. Rendered even
|
||||
# for existing VMs so the file stays in sync with the definition — but see the
|
||||
# template header: cloud-init only APPLIES it on first boot.
|
||||
ansible.builtin.template:
|
||||
src: cloudinit-user.yml.j2
|
||||
dest: "{{ pve_vm_snippet_path }}/{{ item.name }}-user.yml"
|
||||
mode: "0644"
|
||||
loop: "{{ pve_vms | selectattr('cloudinit', 'defined') | selectattr('cloudinit') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
vars:
|
||||
vm: "{{ item }}"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
|
||||
- name: Create missing VMs
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
qm create {{ item.item.vmid }}
|
||||
--name {{ item.item.name }}
|
||||
--description '{{ item.item.description }}'
|
||||
--machine {{ item.item.machine | default('q35') }}
|
||||
--bios {{ item.item.bios | default('ovmf') }}
|
||||
--memory {{ item.item.memory }} --cores {{ item.item.cores }} --cpu host
|
||||
--scsihw virtio-scsi-single
|
||||
--scsi0 {{ item.item.disk }},discard=on,ssd=1
|
||||
--efidisk0 {{ item.item.disk.split(':')[0] }}:1,efitype=4m,pre-enrolled-keys=0
|
||||
--vga {{ item.item.vga | default('virtio') }}
|
||||
{% if item.item.serial | default(false) %}--serial0 socket{% endif %}
|
||||
{% for n in item.item.nets %}--net{{ n.id }} virtio{% if n.mac is defined %}={{ n.mac }}{% endif %},bridge={{ n.bridge }} {% endfor %}
|
||||
{% if item.item.iso is defined %}--ide2 {{ item.item.iso }},media=cdrom --boot order=ide2;scsi0{% else %}--boot order=scsi0{% endif %}
|
||||
{% if item.item.cloudinit | default(false) %}--ide2 {{ item.item.disk.split(':')[0] }}:cloudinit --ipconfig0 ip=dhcp --ciuser {{ item.item.ciuser }}{% endif %}
|
||||
--agent enabled=1 --onboot {{ item.item.onboot | default(0) }} --ostype l26
|
||||
loop: "{{ _vm_exists.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.vmid }} {{ item.item.name }}"
|
||||
when: item.rc != 0
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Point those VMs at their user-data
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
qm set {{ item.vmid }} --cicustom user={{ pve_vm_snippet_storage }}:snippets/{{ item.name }}-user.yml
|
||||
loop: "{{ pve_vms | selectattr('cloudinit', 'defined') | selectattr('cloudinit') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _cicustom
|
||||
changed_when: "'update VM' in (_cicustom.stdout | default(''))"
|
||||
run_once: true
|
||||
delegate_to: "{{ pve_vm_node }}"
|
||||
|
||||
- name: Write base images onto newly created disks
|
||||
ansible.builtin.include_tasks: image.yml
|
||||
# ALL image-backed VMs, not just newly created ones: image.yml decides by
|
||||
# checking for an MBR on the disk. Gating on "was just created" meant a failed
|
||||
# image write could never be repaired by re-running.
|
||||
loop: "{{ pve_vms | selectattr('image', 'defined') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
run_once: true
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ _vm_exists.results
|
||||
| map(attribute='item')
|
||||
| zip(_vm_exists.results | map(attribute='rc'))
|
||||
| map('join', ' rc=') | list }}
|
||||
run_once: true
|
||||
@@ -0,0 +1,45 @@
|
||||
#cloud-config
|
||||
# {{ ansible_managed }}
|
||||
# Full cloud-init user-data for {{ vm.name }} (vmid {{ vm.vmid }}).
|
||||
#
|
||||
# PVE's built-in cloud-init options only cover user/password/keys/network. That
|
||||
# left qemu-guest-agent uninstalled and bootstrap state fragile. Anything a VM
|
||||
# needs to be MANAGEABLE should happen here, on first boot, before Ansible ever
|
||||
# connects.
|
||||
#
|
||||
# NOTE: cloud-init runs `packages`/`runcmd` ONCE per instance. Adding entries
|
||||
# here does not retro-fit an already-provisioned VM — that needs Ansible (or a
|
||||
# `cloud-init clean` + reboot).
|
||||
|
||||
hostname: {{ vm.name }}
|
||||
manage_etc_hosts: true
|
||||
|
||||
users:
|
||||
- name: {{ vm.ciuser }}
|
||||
groups: [adm, sudo]
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: false
|
||||
ssh_authorized_keys:
|
||||
- "{{ pve_vm_ssh_key }}"
|
||||
|
||||
package_update: true
|
||||
# Deliberately NO dist-upgrade at first boot. cloud-init has no retry, and on
|
||||
# this WAN `apt-get dist-upgrade` exits 100 the moment the link blips — which
|
||||
# aborts the whole package module, so qemu-guest-agent never installs and the VM
|
||||
# comes up unmanageable. Keep first boot minimal; Ansible does the rest WITH
|
||||
# retries. (Observed 2026-07-25.)
|
||||
package_upgrade: false
|
||||
packages:
|
||||
{% for p in vm.cloudinit_packages | default(pve_vm_base_packages) %}
|
||||
- {{ p }}
|
||||
{% endfor %}
|
||||
|
||||
runcmd:
|
||||
# qemu-guest-agent gives PVE the guest's IPs, clean shutdown, and fsfreeze for
|
||||
# snapshots. Without it `qm agent` fails and PVE cannot see inside the guest.
|
||||
- [systemctl, enable, --now, qemu-guest-agent]
|
||||
- [systemctl, enable, --now, ssh]
|
||||
|
||||
# Keep the host keys stable across reboots so known_hosts does not churn.
|
||||
ssh_deletekeys: false
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
# 86Box + Avalonia86, installed as AppImages.
|
||||
#
|
||||
# WHY AppImage: 86Box is not in the Ubuntu archive. The alternatives were a
|
||||
# Flatpak (adds a ~1-2GB runtime, and its sandbox complicates letting the
|
||||
# manager pass per-VM config paths) or building from source (large Qt6/SDL2
|
||||
# toolchain, manual updates). Upstream publishes official Linux AppImages for
|
||||
# BOTH 86Box and Avalonia86, so this is a single file each, no runtime, no
|
||||
# sandbox, and the manager can point straight at the binary.
|
||||
|
||||
retro_86box_dir: /opt/86box
|
||||
|
||||
# Pinned. Bump deliberately — a silent upstream change to the emulator is not
|
||||
# something you want arriving with an unrelated play run.
|
||||
retro_86box_version: "6.0"
|
||||
retro_86box_build: "b9001"
|
||||
retro_86box_url: "https://github.com/86Box/86Box/releases/download/v{{ retro_86box_version }}/86Box-Linux-x86_64-{{ retro_86box_build }}.AppImage"
|
||||
|
||||
retro_86box_mgr_version: "1.5.1"
|
||||
retro_86box_mgr_url: "https://github.com/notBald/Avalonia86/releases/download/v{{ retro_86box_mgr_version }}/Avalonia-86-for-Linux-x64-{{ retro_86box_mgr_version }}.AppImage"
|
||||
|
||||
# ROMs are NOT bundled with 86Box (licensing). Without them no machine will
|
||||
# boot — the emulator starts and then cannot find a system BIOS.
|
||||
retro_86box_roms_url: "https://github.com/86Box/roms/archive/refs/tags/v{{ retro_86box_version }}.tar.gz"
|
||||
|
||||
# AppImages need FUSE. Ubuntu 24.04 does not ship libfuse2 by default; without
|
||||
# it an AppImage fails with "dlopen(): error loading libfuse.so.2".
|
||||
# patchelf + setcap are needed to make TAP networking work (see below).
|
||||
retro_86box_packages:
|
||||
- libfuse2t64
|
||||
- patchelf
|
||||
- libcap2-bin
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TAP networking: why the AppImage is extracted instead of run as-is
|
||||
# ---------------------------------------------------------------------------
|
||||
# 86Box's TAP backend creates the tap device itself and enslaves it to a
|
||||
# bridge (verified in src/network/net_tap.c):
|
||||
# ifr.ifr_flags = IFF_TAP | IFF_NO_PI; ioctl(fd, TUNSETIFF, &ifr);
|
||||
# ioctl_or_fail(sock, SIOCBRADDBR, &ifr_bridge); /* create bridge */
|
||||
# ioctl_or_fail(sock, SIOCBRADDIF, &ifr_bridge); /* enslave the tap */
|
||||
# so it needs CAP_NET_ADMIN (upstream's own error text says as much). There is
|
||||
# no "pre-create the tap and hand it over" path -- the setting is a BRIDGE
|
||||
# name, not a tap name.
|
||||
#
|
||||
# File capabilities cannot be applied to the AppImage: the real ELF lives
|
||||
# inside a read-only squashfs mounted through FUSE with nosuid, where the
|
||||
# kernel ignores file caps entirely. So the AppImage is extracted once and the
|
||||
# extracted binary carries the caps.
|
||||
#
|
||||
# That in turn breaks the AppImage's own library loading, because a file with
|
||||
# capabilities runs in secure-execution mode (AT_SECURE=1) and glibc drops
|
||||
# LD_LIBRARY_PATH -- which is exactly how AppRun points the binary at its
|
||||
# bundled Qt. Two patches fix it, and BOTH are load-bearing:
|
||||
# 1. PT_INTERP is the RELATIVE path "lib64/ld-linux-x86-64.so.2"; it must be
|
||||
# made absolute or exec fails with "required file not found".
|
||||
# 2. The rpath must be DT_RPATH (--force-rpath), NOT the modern DT_RUNPATH.
|
||||
# RUNPATH is not inherited by transitive dependencies, and 8 of the
|
||||
# bundled libraries (libicuuc, libpcre2-16, libFLAC, ...) are needed by
|
||||
# Qt itself rather than by 86Box, so with RUNPATH they are "not found".
|
||||
retro_86box_app_dir: "{{ retro_86box_dir }}/app"
|
||||
retro_86box_bin: "{{ retro_86box_app_dir }}/usr/local/bin/86Box"
|
||||
retro_86box_wrapper: /usr/local/bin/86box
|
||||
retro_86box_interp: /lib64/ld-linux-x86-64.so.2
|
||||
retro_86box_caps: "CAP_NET_RAW,CAP_NET_ADMIN=eip"
|
||||
|
||||
# Taken from the AppImage's own AppRun.env (APPDIR_LIBRARY_PATH), made
|
||||
# absolute. Keep in sync if a future 86Box release changes its bundle layout.
|
||||
retro_86box_rpath_dirs:
|
||||
- "{{ retro_86box_app_dir }}/lib/x86_64-linux-gnu"
|
||||
- "{{ retro_86box_app_dir }}/usr/lib"
|
||||
- "{{ retro_86box_app_dir }}/usr/lib/x86_64-linux-gnu"
|
||||
- "{{ retro_86box_app_dir }}/lib/x86_64"
|
||||
|
||||
# The bridge 86Box should attach its taps to. Must match tap_bridge_name.
|
||||
retro_86box_tap_bridge: br-retro
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
- name: Install AppImage runtime dependency
|
||||
ansible.builtin.apt:
|
||||
name: "{{ retro_86box_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
register: _fuse
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _fuse is succeeded
|
||||
|
||||
- name: Create the 86Box directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ retro_86box_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download 86Box and Avalonia86
|
||||
# The WAN drops out; get_url resumes rather than restarting from zero.
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ item.url }}"
|
||||
dest: "{{ retro_86box_dir }}/{{ item.name }}"
|
||||
mode: "0755"
|
||||
loop:
|
||||
- { name: "86Box.AppImage", url: "{{ retro_86box_url }}" }
|
||||
- { name: "Avalonia86.AppImage", url: "{{ retro_86box_mgr_url }}" }
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _dl
|
||||
retries: 3
|
||||
delay: 20
|
||||
until: _dl is succeeded
|
||||
|
||||
- name: Check whether ROMs are already extracted
|
||||
ansible.builtin.stat:
|
||||
path: "{{ retro_86box_dir }}/roms/machines"
|
||||
register: _roms
|
||||
|
||||
- name: Create the roms directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ retro_86box_dir }}/roms"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download and extract the ROM set
|
||||
# Extract INTO roms/, stripping the tarball's own roms-<ver>/ wrapper.
|
||||
# Extracting to the parent with --strip-components=1 dumps machines/, floppy/,
|
||||
# hdd/ ... loose next to the binaries, which is NOT the layout 86Box and
|
||||
# Avalonia86 expect (they want everything under roms/).
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ retro_86box_roms_url }}"
|
||||
dest: "{{ retro_86box_dir }}/roms"
|
||||
remote_src: true
|
||||
extra_opts: [--strip-components=1]
|
||||
creates: "{{ retro_86box_dir }}/roms/machines"
|
||||
when: not _roms.stat.exists
|
||||
register: _romdl
|
||||
retries: 3
|
||||
delay: 20
|
||||
until: _romdl is succeeded
|
||||
|
||||
- name: Extract the AppImage and grant it CAP_NET_ADMIN for TAP networking
|
||||
ansible.builtin.include_tasks: privileged.yml
|
||||
|
||||
- name: Add desktop launchers
|
||||
ansible.builtin.copy:
|
||||
dest: "/usr/share/applications/{{ item.file }}"
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name={{ item.name }}
|
||||
Exec={{ item.bin }}
|
||||
Icon=computer
|
||||
Categories=System;Emulator;
|
||||
Terminal=false
|
||||
loop:
|
||||
- file: "avalonia86.desktop"
|
||||
name: "Avalonia86 (86Box manager)"
|
||||
bin: "{{ retro_86box_dir }}/Avalonia86.AppImage"
|
||||
# NOT the .AppImage: only the extracted binary carries CAP_NET_ADMIN, so
|
||||
# launching the AppImage would silently lose TAP networking.
|
||||
- file: "86box.desktop"
|
||||
name: "86Box"
|
||||
bin: "{{ retro_86box_wrapper }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
|
||||
- name: Verify 86Box actually runs
|
||||
# Runs the CAPABILITY-BEARING binary, which is the one that must work.
|
||||
# QT_QPA_PLATFORM=offscreen is REQUIRED: 86Box is a Qt GUI app and still
|
||||
# initialises a display for --help, so it dies headless with
|
||||
# "could not connect to display / Could not load the Qt platform plugin xcb".
|
||||
# This also proves the patchelf work: under secure-execution mode (which
|
||||
# file capabilities trigger) glibc drops LD_LIBRARY_PATH, so if the RPATH or
|
||||
# the interpreter were wrong this step fails with "required file not found".
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ retro_86box_wrapper }} --help"
|
||||
environment:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
register: _ver
|
||||
changed_when: false
|
||||
failed_when: "'86box' not in (_ver.stdout + _ver.stderr) | lower and 'usage' not in (_ver.stdout + _ver.stderr) | lower"
|
||||
|
||||
- name: Read back the capabilities actually on the binary
|
||||
ansible.builtin.command:
|
||||
cmd: "getcap {{ retro_86box_bin }}"
|
||||
register: _capcheck
|
||||
changed_when: false
|
||||
|
||||
- name: Verify the capabilities survived
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'cap_net_admin' in _capcheck.stdout"
|
||||
- "'cap_net_raw' in _capcheck.stdout"
|
||||
fail_msg: "86Box has no CAP_NET_ADMIN; TAP networking will fail to allocate a tap device"
|
||||
success_msg: "{{ _capcheck.stdout | trim }}"
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "86Box: {{ retro_86box_wrapper }} -> {{ retro_86box_bin }}"
|
||||
- "caps: {{ _capcheck.stdout | trim }}"
|
||||
- "roms: {{ retro_86box_dir }}/roms"
|
||||
- "ISOs: /mnt/iso (read-only)"
|
||||
- "TAP: set each NIC to 'TAP' with bridge '{{ retro_86box_tap_bridge }}'"
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
# Extract the AppImage and give the real binary CAP_NET_ADMIN so TAP
|
||||
# networking works. See the long comment in defaults/main.yml for why every
|
||||
# step here is necessary.
|
||||
|
||||
- name: Read the extracted build id
|
||||
# The extracted tree is a derived artifact; it must be rebuilt whenever the
|
||||
# pinned AppImage build changes, or a version bump would leave the old
|
||||
# binary in place while the AppImage next to it says otherwise.
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ retro_86box_app_dir }}/.build-id"
|
||||
register: _bid
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Decide whether the AppImage must be re-extracted
|
||||
ansible.builtin.set_fact:
|
||||
_86box_extract: >-
|
||||
{{ _bid.content is not defined
|
||||
or (_bid.content | b64decode | trim) != retro_86box_build }}
|
||||
|
||||
- name: Extract the AppImage
|
||||
# --appimage-extract always writes ./squashfs-root in the CWD and refuses to
|
||||
# target a directory, hence the extract-then-move.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -e
|
||||
rm -rf "{{ retro_86box_app_dir }}" "{{ retro_86box_dir }}/squashfs-root"
|
||||
cd "{{ retro_86box_dir }}"
|
||||
./86Box.AppImage --appimage-extract >/dev/null
|
||||
mv squashfs-root "{{ retro_86box_app_dir }}"
|
||||
when: _86box_extract
|
||||
changed_when: true
|
||||
|
||||
- name: Read the current ELF interpreter
|
||||
ansible.builtin.command:
|
||||
cmd: "patchelf --print-interpreter {{ retro_86box_bin }}"
|
||||
register: _interp
|
||||
changed_when: false
|
||||
|
||||
- name: Make the ELF interpreter absolute
|
||||
ansible.builtin.command:
|
||||
cmd: "patchelf --set-interpreter {{ retro_86box_interp }} {{ retro_86box_bin }}"
|
||||
when: _interp.stdout | trim != retro_86box_interp
|
||||
changed_when: true
|
||||
|
||||
- name: Read the current rpath
|
||||
ansible.builtin.command:
|
||||
cmd: "patchelf --print-rpath {{ retro_86box_bin }}"
|
||||
register: _rpath
|
||||
changed_when: false
|
||||
|
||||
- name: Bake the bundled library directories in as DT_RPATH
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
patchelf --force-rpath --set-rpath
|
||||
"{{ retro_86box_rpath_dirs | join(':') }}" {{ retro_86box_bin }}
|
||||
when: _rpath.stdout | trim != (retro_86box_rpath_dirs | join(':'))
|
||||
changed_when: true
|
||||
|
||||
- name: Verify no library is left unresolved
|
||||
# Must pass BEFORE setcap is worth doing -- a capability binary that cannot
|
||||
# load its libraries fails with a misleading "required file not found".
|
||||
ansible.builtin.shell:
|
||||
cmd: "ldd {{ retro_86box_bin }} 2>&1 | grep -c 'not found' || true"
|
||||
register: _missing
|
||||
changed_when: false
|
||||
failed_when: (_missing.stdout | trim | int) != 0
|
||||
|
||||
- name: Read the current capabilities
|
||||
ansible.builtin.command:
|
||||
cmd: "getcap {{ retro_86box_bin }}"
|
||||
register: _caps
|
||||
changed_when: false
|
||||
|
||||
- name: Grant CAP_NET_ADMIN and CAP_NET_RAW
|
||||
# NOTE: must run AFTER patchelf. patchelf rewrites the file and drops the
|
||||
# security.capability xattr, so setting caps first silently loses them.
|
||||
ansible.builtin.command:
|
||||
cmd: "setcap '{{ retro_86box_caps }}' {{ retro_86box_bin }}"
|
||||
when: "'cap_net_admin' not in _caps.stdout or 'cap_net_raw' not in _caps.stdout"
|
||||
changed_when: true
|
||||
|
||||
- name: Record the extracted build id
|
||||
ansible.builtin.copy:
|
||||
content: "{{ retro_86box_build }}\n"
|
||||
dest: "{{ retro_86box_app_dir }}/.build-id"
|
||||
mode: "0644"
|
||||
|
||||
- name: Install the launcher wrapper
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ retro_86box_wrapper }}"
|
||||
mode: "0755"
|
||||
content: |
|
||||
#!/bin/sh
|
||||
# Managed by Ansible (roles/retro_86box).
|
||||
#
|
||||
# Runs the EXTRACTED 86Box, not the AppImage: only the extracted binary
|
||||
# can carry CAP_NET_ADMIN, which 86Box needs to create its tap device
|
||||
# and enslave it to {{ retro_86box_tap_bridge }}. Point Avalonia86 at
|
||||
# this path, not at the .AppImage.
|
||||
exec {{ retro_86box_bin }} "$@"
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
# Remote desktop for the 86Box host.
|
||||
#
|
||||
# RDP, not VNC — because 86Box needs SOUND and VNC carries no audio. xrdp
|
||||
# redirects audio; that is the whole reason for this choice.
|
||||
#
|
||||
# xrdp destroys a session only on explicit LOGOUT, not on disconnect, so
|
||||
# emulator instances survive closing the client. That is why 86Box is left as a
|
||||
# normal desktop app rather than forced into a systemd unit: several GUI
|
||||
# instances under a manager is a desktop, and pretending otherwise fights the
|
||||
# tooling for no benefit.
|
||||
|
||||
retro_desktop_packages:
|
||||
- xfce4 # light, and xrdp-friendly
|
||||
- xfce4-terminal
|
||||
- dbus-x11
|
||||
- xrdp
|
||||
- xorgxrdp
|
||||
|
||||
# Audio over RDP — the whole reason this box uses RDP rather than VNC.
|
||||
#
|
||||
# Ubuntu 24.04 is PipeWire-based, and it ships a PIPEWIRE-native xrdp module
|
||||
# (`pipewire-module-xrdp`), so the old pulseaudio-module-xrdp build dance is not
|
||||
# needed. It currently arrives as a transitive dependency of xrdp — listed here
|
||||
# EXPLICITLY so a future dependency change cannot silently kill sound.
|
||||
retro_desktop_audio: true
|
||||
retro_desktop_audio_packages:
|
||||
- pipewire-module-xrdp # provides /etc/xdg/autostart/pipewire-xrdp.desktop
|
||||
- pipewire-pulse
|
||||
- pulseaudio-utils # paplay/pactl, for testing inside a session
|
||||
|
||||
# Channels that must be enabled in xrdp.ini for audio to reach the client.
|
||||
retro_desktop_audio_channels: [rdpsnd, drdynvc]
|
||||
|
||||
# What to rename xorgxrdp's pointer to, so 86Box can capture the mouse.
|
||||
#
|
||||
# This string is not cosmetic: it is matched CHARACTER FOR CHARACTER against
|
||||
# 86Box's allow-list of pointer devices that report absolute coordinates
|
||||
# (src/qt/xinput2_mouse.cpp, xinput2_get_xtest_pointer()). Change it and mouse
|
||||
# capture breaks again. The full reasoning is in tasks/main.yml.
|
||||
retro_desktop_xrdp_pointer_name: "TigerVNC pointer"
|
||||
retro_desktop_xrdp_xorg_conf: /etc/X11/xrdp/xorg.conf
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
- name: Install the desktop and xrdp
|
||||
ansible.builtin.apt:
|
||||
name: "{{ retro_desktop_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
register: _pkgs
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _pkgs is succeeded # the WAN drops out; do not fail a long run on one blip
|
||||
|
||||
- name: Install audio support
|
||||
ansible.builtin.apt:
|
||||
name: "{{ retro_desktop_audio_packages }}"
|
||||
state: present
|
||||
register: _apkgs
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _apkgs is succeeded
|
||||
when: retro_desktop_audio | bool
|
||||
|
||||
- name: Use XFCE for xrdp sessions
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/skel/.xsession
|
||||
content: "xfce4-session\n"
|
||||
mode: "0644"
|
||||
|
||||
- name: Give the existing user an .xsession too
|
||||
ansible.builtin.copy:
|
||||
dest: "/home/{{ ansible_user }}/.xsession"
|
||||
content: "xfce4-session\n"
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: "0644"
|
||||
|
||||
# ⚠️ NO WINDOW MANAGER after login? It is a saved session, and it is
|
||||
# self-perpetuating. xfce4-session restores exactly the client list in
|
||||
# ~/.cache/sessions/xfce4-session-<host>:<display>, so once xfwm4 is missing
|
||||
# from that list every later login is WM-less too — no title bars, no
|
||||
# Applications menu, hence no way to log out and no way to fix it from inside.
|
||||
# Seen 2026-07-28: the file listed only xfsettingsd, xfce4-panel, Thunar and
|
||||
# xfdesktop. The distro's failsafe session does NOT cover this — it only runs
|
||||
# when you pick Failsafe at the greeter, which xrdp never offers.
|
||||
#
|
||||
# How xfwm4 falls out of the list: any xfwm4 started outside the session
|
||||
# manager. `xfwm4 --replace` over SSH has no SESSION_MANAGER in its environment
|
||||
# and says so ("Failed to connect to session manager"), so the next save does
|
||||
# not record it. That is what happened here — the recovery caused the relapse.
|
||||
#
|
||||
# Recovery, from SSH:
|
||||
# DISPLAY=:10 XAUTHORITY=~/.Xauthority xfwm4 --replace &
|
||||
# rm ~/.cache/sessions/xfce4-session-*
|
||||
# Prevention, applied on this host by hand rather than by a task:
|
||||
# xfconf-query -c xfce4-session -p /general/SaveOnExit -n -t bool -s false
|
||||
# Deliberately NOT automated: the system-wide xfce4-session.xml also defines
|
||||
# the failsafe session, so a role would have to either template the whole
|
||||
# distro file or add python3-lxml for the xml module, and the per-user file it
|
||||
# would otherwise write is rewritten by xfconfd at every logout — a permanently
|
||||
# "changed" task guarding against an operator mistake.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mouse capture in 86Box: rename xorgxrdp's pointer device
|
||||
# ---------------------------------------------------------------------------
|
||||
# xorgxrdp's pointer LIES about itself. xrdpmouse/rdpMouse.c labels its two
|
||||
# valuators REL_X/REL_Y — so XInput2 advertises "Mode: relative" with the dummy
|
||||
# range -1..-1 — and then posts every event as
|
||||
# xf86PostMotionEvent(device, TRUE /* is_absolute */, 0, 2, x, y)
|
||||
# i.e. ABSOLUTE screen coordinates down a nominally RELATIVE axis. RDP itself
|
||||
# carries no relative motion, so it has nothing else to send.
|
||||
#
|
||||
# 86Box's X11 input backend (src/qt/xinput2_mouse.cpp) reads XI_RawMotion and
|
||||
# branches on exactly that axis mode: for a "relative" axis it feeds the raw
|
||||
# valuator straight in as a movement delta. Under xrdp that "delta" is an
|
||||
# absolute pixel coordinate, so the instant you click to capture, the emulated
|
||||
# pointer is thrown into a corner and stays pinned — which presents as the
|
||||
# mouse not being captured at all.
|
||||
#
|
||||
# Upstream already handles this failure mode, just not for us: VNC servers
|
||||
# inject through XTEST, which tells the same lie, so 86Box keeps an allow-list
|
||||
# of pointer device NAMES it treats as absolute — "TigerVNC pointer" and
|
||||
# "Virtual core XTEST pointer" — and those take a correct absolute->delta path.
|
||||
# An X input device is named after its xorg.conf Identifier, so renaming xrdp's
|
||||
# pointer onto that list is the whole fix. Verified safe on this host: the
|
||||
# identifier occurs only in the two lines this task rewrites (ServerLayout and
|
||||
# the InputDevice section), and xrdp binds the device by DRIVER ("xrdpmouse"),
|
||||
# never by identifier.
|
||||
#
|
||||
# Known limit, not fixable here: motion still stops when the CLIENT's cursor
|
||||
# hits the edge of the remote screen, because there is no relative-motion event
|
||||
# to send. xrdp PR #3091 (TS_RELPOINTER) is still an unmerged draft as of
|
||||
# 0.9.24 / xorgxrdp 0.9.19. Escaping that needs a different transport, not a
|
||||
# different setting.
|
||||
#
|
||||
# sesman starts a fresh Xorg per login, so nothing needs restarting — but a
|
||||
# session that is already running keeps the old device until the user LOGS OUT.
|
||||
# Disconnecting is not enough (that is the point of this host's setup).
|
||||
- name: Present the xrdp pointer to 86Box as the absolute device it really is
|
||||
ansible.builtin.replace:
|
||||
path: "{{ retro_desktop_xrdp_xorg_conf }}"
|
||||
regexp: '"xrdpMouse"'
|
||||
replace: '"{{ retro_desktop_xrdp_pointer_name }}"'
|
||||
|
||||
# Stop needrestart from ever restarting xrdp-sesman. It has stranded a live
|
||||
# desktop twice now: 2026-07-25 (our own `apt install` pulled libcap2) and
|
||||
# 2026-07-28 (unattended-upgrades pulled libc6 at 06:28). Both times sesman came
|
||||
# back with an EMPTY session table, so it could no longer reattach the running
|
||||
# display — every reconnect started a new one, xfce4-session refused to run
|
||||
# twice for the same user, and each new session died in about a second. The
|
||||
# desktop and its 86Box VM keep running, just permanently unreachable.
|
||||
#
|
||||
# `NEEDRESTART_MODE: l` in retrolab.yml only covers playbook runs. It does
|
||||
# nothing about unattended-upgrades, which is what actually did it the second
|
||||
# time — so the exemption has to live on the host.
|
||||
#
|
||||
# This is not a novel idea: needrestart.conf already ships `override_rc`
|
||||
# entries pinning gdm, sddm, xdm and friends to 0, because restarting the thing
|
||||
# that owns your login sessions costs more than it fixes. xrdp-sesman is the
|
||||
# same class of service and simply is not on that list.
|
||||
#
|
||||
# The trade, stated plainly: sesman keeps running against the old libc until
|
||||
# the host is rebooted. That is the intended outcome — a stale session manager
|
||||
# that works beats a fresh one that has orphaned every session.
|
||||
- name: Never let needrestart bounce the xrdp session manager
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/needrestart/conf.d/50-xrdp.conf
|
||||
# conf.d is parsed after needrestart.conf and merges into the hash, so this
|
||||
# ADDS a key rather than replacing the shipped defaults. Assigning
|
||||
# $nrconf{override_rc} wholesale here would silently drop them.
|
||||
content: |
|
||||
# Managed by Ansible (proxmox/ansible/roles/retro_desktop). See that role
|
||||
# for why: restarting sesman empties its session table and strands every
|
||||
# live xrdp desktop until the user logs out.
|
||||
$nrconf{override_rc}{qr(^xrdp)} = 0;
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Enable xrdp
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ item }}"
|
||||
enabled: true
|
||||
state: started
|
||||
loop: [xrdp, xrdp-sesman]
|
||||
|
||||
# Audio silently not working is the classic xrdp complaint; assert the pieces
|
||||
# rather than discover it mid-session.
|
||||
- name: Read the xrdp channel configuration
|
||||
ansible.builtin.command:
|
||||
cmd: sed -n '/^\[Channels\]/,/^\[/p' /etc/xrdp/xrdp.ini
|
||||
register: _chan
|
||||
changed_when: false
|
||||
# Reads nothing and writes nothing, but `command` is skipped under --check by
|
||||
# default, which left `_chan.stdout` empty and made the assert below fail
|
||||
# every dry run with a false "audio is broken".
|
||||
check_mode: false
|
||||
when: retro_desktop_audio | bool
|
||||
|
||||
- name: Assert the audio channels are enabled
|
||||
ansible.builtin.assert:
|
||||
that: "'{{ item }}=true' in _chan.stdout"
|
||||
fail_msg: "xrdp channel {{ item }} is not enabled — audio will not reach the client."
|
||||
quiet: true
|
||||
loop: "{{ retro_desktop_audio_channels }}"
|
||||
when: retro_desktop_audio | bool
|
||||
|
||||
- name: Assert the PipeWire xrdp module is present
|
||||
ansible.builtin.stat:
|
||||
path: /etc/xdg/autostart/pipewire-xrdp.desktop
|
||||
register: _pwx
|
||||
when: retro_desktop_audio | bool
|
||||
|
||||
- name: Fail if the audio module is missing
|
||||
ansible.builtin.assert:
|
||||
that: _pwx.stat.exists
|
||||
fail_msg: "pipewire-module-xrdp is not installed; xrdp sessions will have no sound."
|
||||
quiet: true
|
||||
when: retro_desktop_audio | bool
|
||||
|
||||
- name: Confirm xrdp is listening on 3389
|
||||
ansible.builtin.wait_for:
|
||||
port: 3389
|
||||
timeout: 30
|
||||
@@ -0,0 +1,864 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hayes AT modem emulator with a socket DTE side.
|
||||
|
||||
The emulated machine's serial port connects here (unix socket or TCP). Dialling
|
||||
either opens a TCP connection (BBS) or hands the raw line to pppd -- i.e. an ISP
|
||||
terminal server, which is what a period PC actually dialled.
|
||||
|
||||
It also answers. --line gives the modem a phone number: an inbound TCP connection
|
||||
rings the DTE, and the guest picks up with ATA (or automatically, once it has set
|
||||
S0). That is the half NT4's Remote Access Server needs to *receive* calls, and it
|
||||
is what lets one retro guest dial another.
|
||||
|
||||
Why not tcpser: its only socket DTE transport is ip232, which doubles 0xFF and
|
||||
steals FF 00 / FF 01 to carry DTR. Every PPP frame starts FF 03, so ip232 eats
|
||||
the link. This is 8-bit clean, and speaking unix sockets natively removes the
|
||||
socat + pty sandwich (PVE's `-serial0 socket` plugs straight in).
|
||||
|
||||
One process is one modem on one line, because that is what a modem is. An ISP's
|
||||
T1 into a rack of them is N processes on N ports; a single number in front of the
|
||||
rack is a hunt group, which is a dispatcher this does not have.
|
||||
|
||||
No modem control lines: a QEMU socket chardev carries none, so DCD reads as
|
||||
permanently asserted in the guest and the NO CARRIER result code is the only
|
||||
carrier signal available. Guests set &C1 (DCD follows carrier) and &D2 (drop DTR
|
||||
to hang up) anyway; neither can be honoured. Terminal software parses the result
|
||||
code and copes. PPP cannot see the drop and falls back to LCP echo timeouts.
|
||||
|
||||
qm set 102 -serial0 socket # QEMU LISTENS on the socket, so we connect to it
|
||||
atmodem.py --connect /var/run/qemu-server/102.serial0 --phonebook pb.txt \
|
||||
--line 6102 --pppd 'pppd notty 10.62.0.1:10.62.0.2 require-pap lock'
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
# Hayes guard time: the silence required either side of +++ so that binary
|
||||
# payloads containing +++ do not drop the link.
|
||||
GUARD = 1.0
|
||||
RING = 4.0 # seconds between RING results, as on a real line
|
||||
RINGS = 8 # then the caller has given up
|
||||
|
||||
log = logging.getLogger("atmodem")
|
||||
|
||||
CODES = {"OK": 0, "CONNECT": 1, "RING": 2, "NO CARRIER": 3, "ERROR": 4, "BUSY": 7}
|
||||
|
||||
|
||||
def digits(s):
|
||||
"""Dialers emit ATDT9,1-555-1212 and friends; only the digits identify it."""
|
||||
return "".join(c for c in s if c.isdigit())
|
||||
|
||||
|
||||
def load_phonebook(path):
|
||||
"""86Box's own format on purpose -- <number><whitespace><target>, one per
|
||||
line -- so a single file serves both emulators. Target is host:port, or the
|
||||
literal 'ppp' to hand the call to pppd."""
|
||||
book = {}
|
||||
if not path:
|
||||
return book
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
parts = line.split("#", 1)[0].split()
|
||||
if len(parts) >= 2:
|
||||
book[digits(parts[0])] = parts[1]
|
||||
return book
|
||||
|
||||
|
||||
class Phonebook:
|
||||
"""A phonebook that re-reads itself when the file changes.
|
||||
|
||||
Drop-in for the dict it replaces (both answer .get). Without this, editing a
|
||||
number means restarting the modem, which drops whatever call is up -- the
|
||||
single most annoying thing about operating this, and three lines to fix.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path, self.mtime, self.book = path, None, {}
|
||||
|
||||
def get(self, number):
|
||||
try:
|
||||
mtime = os.stat(self.path).st_mtime if self.path else None
|
||||
except OSError:
|
||||
mtime = None # deleted mid-flight: keep serving the last good copy
|
||||
if mtime is not None and mtime != self.mtime:
|
||||
self.mtime, self.book = mtime, load_phonebook(self.path)
|
||||
log.info("phonebook reloaded: %d numbers", len(self.book))
|
||||
return self.book.get(number)
|
||||
|
||||
|
||||
def parse(body):
|
||||
"""Split an AT command line into (cmd, arg) pairs.
|
||||
|
||||
The one subtlety worth code: extended commands are &X / %X / \\X, so a naive
|
||||
search for 'D' fires on the &D2 in every dialer's init string and tries to
|
||||
dial "2". Consume the prefix first. D itself swallows the rest of the line,
|
||||
per Hayes.
|
||||
"""
|
||||
out, i = [], 0
|
||||
while i < len(body):
|
||||
c = body[i]
|
||||
if c in " \t":
|
||||
i += 1
|
||||
continue
|
||||
prefix = ""
|
||||
if c in "&%\\":
|
||||
prefix, i = c, i + 1
|
||||
if i >= len(body):
|
||||
break
|
||||
c = body[i]
|
||||
c, i = c.upper(), i + 1
|
||||
if not prefix and c == "D":
|
||||
out.append(("D", body[i:].strip()))
|
||||
break
|
||||
if not prefix and c == "S":
|
||||
# Sn=v is the one command whose register number matters (S0 is
|
||||
# auto-answer), so keep them apart: ("S0", "1"), not ("S", "1").
|
||||
m = re.match(r"(\d+)\s*=\s*(\d+)", body[i:])
|
||||
if m:
|
||||
i += m.end()
|
||||
out.append(("S" + m.group(1), m.group(2)))
|
||||
continue
|
||||
m = re.match(r"(\d*)\s*(?:=\s*(\d+))?", body[i:])
|
||||
i += m.end()
|
||||
out.append((prefix + c, m.group(2) if m.group(2) is not None else m.group(1)))
|
||||
return out
|
||||
|
||||
|
||||
class Telnet:
|
||||
"""A minimal telnet client, wrapping a peer stream with read/write/drain.
|
||||
|
||||
A real telnetd opens with `IAC WILL ECHO, IAC WILL SGA`; raw-piped, those
|
||||
six bytes land on the guest's screen as garbage before the login prompt, and
|
||||
an un-doubled 0xFF corrupts any 8-bit transfer. Opt in per phonebook entry
|
||||
('telnet:host[:port]') -- it MUST stay off for PPP and guest-to-guest links,
|
||||
where 0xFF is data, which is the same toggle 86Box gets wrong by default.
|
||||
"""
|
||||
|
||||
IAC, SE, SB, WILL, WONT, DO, DONT = 255, 240, 250, 251, 252, 253, 254
|
||||
AGREE = (1, 3) # ECHO and SUPPRESS-GO-AHEAD: what a dumb terminal wants
|
||||
|
||||
def __init__(self, reader, writer):
|
||||
self.r, self.w = reader, writer
|
||||
self.iac = self.sb = self.sb_iac = False
|
||||
self.verb = 0
|
||||
|
||||
def _filter(self, data):
|
||||
"""Split a chunk into (data for the guest, negotiation for the host)."""
|
||||
out, reply = bytearray(), bytearray()
|
||||
for b in data:
|
||||
if self.sb: # skip subnegotiation payload until IAC SE
|
||||
if self.sb_iac and b == self.SE:
|
||||
self.sb = False
|
||||
self.sb_iac = b == self.IAC and not self.sb_iac
|
||||
elif self.verb: # this byte is the option the verb applies to
|
||||
if self.verb == self.WILL:
|
||||
yes = self.DO if b in self.AGREE else self.DONT
|
||||
reply += bytes((self.IAC, yes, b))
|
||||
elif self.verb == self.DO:
|
||||
reply += bytes((self.IAC, self.WONT, b)) # we offer nothing
|
||||
self.verb = 0 # WONT/DONT need no answer
|
||||
elif self.iac:
|
||||
self.iac = False
|
||||
if b == self.IAC:
|
||||
out.append(self.IAC) # doubled 0xFF is literal data
|
||||
elif b in (self.WILL, self.WONT, self.DO, self.DONT):
|
||||
self.verb = b
|
||||
elif b == self.SB:
|
||||
self.sb, self.sb_iac = True, False
|
||||
elif b == self.IAC:
|
||||
self.iac = True
|
||||
else:
|
||||
out.append(b)
|
||||
return bytes(out), bytes(reply)
|
||||
|
||||
async def read(self, n):
|
||||
while True: # a chunk of pure negotiation yields nothing; go round again
|
||||
data = await self.r.read(n)
|
||||
if not data:
|
||||
return b""
|
||||
out, reply = self._filter(data)
|
||||
if reply:
|
||||
self.w.write(reply)
|
||||
await self.w.drain()
|
||||
if out:
|
||||
return out
|
||||
|
||||
def write(self, data):
|
||||
self.w.write(data.replace(b"\xff", b"\xff\xff"))
|
||||
|
||||
async def drain(self):
|
||||
await self.w.drain()
|
||||
|
||||
|
||||
class Modem:
|
||||
def __init__(self, dte_r, dte_w, book, pppd, guard=GUARD, ring=RING, hook=None):
|
||||
self.dte_r, self.dte_w = dte_r, dte_w
|
||||
self.book, self.pppd, self.guard = book, pppd, guard
|
||||
self.ring_gap = ring # not self.ring -- that name is the method below
|
||||
self.hook = hook # called whenever the line goes on/off hook
|
||||
self.echo, self.verbose = True, True
|
||||
self.s = {} # S registers; only S0 (auto-answer) is honoured
|
||||
self.staged = "" # digits from ATD...; dialled in stages
|
||||
self.pushback = b"" # bytes after a "+++ATH"-style escape
|
||||
self.board = None # set when this modem is one line of a switchboard
|
||||
self.peer = None # (reader, writer, close) while off-hook
|
||||
self.incoming = None # same, while the phone is ringing
|
||||
self.autoanswer = asyncio.Event()
|
||||
|
||||
# -- DTE plumbing ------------------------------------------------------
|
||||
|
||||
async def reply(self, text):
|
||||
# V1 is verbose words, V0 is the numeric code. Both are in the wild.
|
||||
log.info("DCE< %s", text)
|
||||
out = f"\r\n{text}\r\n" if self.verbose else f"{CODES[text]}\r"
|
||||
self.dte_w.write(out.encode())
|
||||
await self.dte_w.drain()
|
||||
|
||||
async def _read_byte(self):
|
||||
"""One byte from the DTE, but interruptible by auto-answer.
|
||||
|
||||
Without the race, S0 auto-answer could never fire: between calls the
|
||||
modem sits blocked here, and a guest that has set S0 sends nothing at
|
||||
all -- it just waits for CONNECT. Returns None to mean "answer now".
|
||||
"""
|
||||
if self.pushback: # command that arrived glued to a +++ escape
|
||||
b, self.pushback = self.pushback[:1], self.pushback[1:]
|
||||
return b
|
||||
read = asyncio.ensure_future(self.dte_r.read(1))
|
||||
ring = asyncio.ensure_future(self.autoanswer.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{read, ring}, return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in pending:
|
||||
task.cancel() # safe: unread bytes stay in the StreamReader buffer
|
||||
if read in done:
|
||||
return read.result() # a real byte wins; the event survives for next time
|
||||
self.autoanswer.clear()
|
||||
return None
|
||||
|
||||
async def read_command(self):
|
||||
"""Collect one CR-terminated line, honouring echo and backspace."""
|
||||
buf = bytearray()
|
||||
while True:
|
||||
b = await self._read_byte()
|
||||
if b is None:
|
||||
return "ATA"
|
||||
if not b:
|
||||
return None
|
||||
if self.echo:
|
||||
self.dte_w.write(b)
|
||||
await self.dte_w.drain()
|
||||
if b in (b"\r", b"\n"):
|
||||
if buf:
|
||||
return buf.decode("latin-1")
|
||||
buf.clear()
|
||||
elif b == b"\x08":
|
||||
buf[-1:] = b""
|
||||
else:
|
||||
buf += b
|
||||
|
||||
# -- command mode ------------------------------------------------------
|
||||
|
||||
async def command(self, line):
|
||||
log.info("DTE> %s", line)
|
||||
if not line[:2].upper() == "AT":
|
||||
return await self.reply("ERROR")
|
||||
for cmd, arg in parse(line[2:]):
|
||||
if cmd == "D":
|
||||
return await self.dial(arg)
|
||||
if cmd == "A":
|
||||
return await self.answer()
|
||||
if cmd == "H":
|
||||
await self.hangup()
|
||||
elif cmd == "O" and self.peer:
|
||||
return await self.online()
|
||||
elif cmd == "E":
|
||||
self.echo = arg != "0"
|
||||
elif cmd == "V":
|
||||
self.verbose = arg != "0"
|
||||
elif cmd.startswith("S") and cmd[1:].isdigit():
|
||||
self.s[int(cmd[1:])] = int(arg or 0)
|
||||
# Everything else (Z, &F, &C1, &D2, S0=0, X4, ...) is accepted and
|
||||
# ignored on purpose: answering OK to unknown setup commands is what
|
||||
# makes an emulated modem work with dialers you have never seen.
|
||||
await self.reply("OK")
|
||||
|
||||
async def dial(self, num):
|
||||
# A trailing ';' means "dial, then return to command state" -- Windows
|
||||
# TAPI opens every call with a bare `ATDT;` and only then sends the
|
||||
# digits, so answering NO CARRIER here kills the call before it starts.
|
||||
# Accumulate the staged digits and answer OK, as a real modem does.
|
||||
num = num.rstrip()
|
||||
if num.endswith(";"):
|
||||
self.staged += num[:-1]
|
||||
return await self.reply("OK")
|
||||
num, self.staged = self.staged + num, ""
|
||||
|
||||
# D takes an optional dial modifier -- T(one) or P(ulse). Strip exactly
|
||||
# one, never lstrip(): dialling a host called "telnet.example.com" must
|
||||
# keep its 't'. Harmless for phonebook lookups, which go through
|
||||
# digits(), but it lands inside the hostname when dialling an address.
|
||||
if num[:1] in ("T", "P", "t", "p"):
|
||||
num = num[1:].strip()
|
||||
|
||||
# Exact-digit lookup, same as 86Box: an outside-line prefix (ATDT9,555...)
|
||||
# is part of the number and will miss. Turn the prefix off in the dialer.
|
||||
target = self.book.get(digits(num)) or (num if ":" in num else None)
|
||||
if not target:
|
||||
return await self.reply("NO CARRIER")
|
||||
try:
|
||||
if target.startswith("vm:"):
|
||||
# Internal call to another line of this switchboard: no TCP hop,
|
||||
# no per-line port, and the callee really rings rather than
|
||||
# being handed raw bytes.
|
||||
name = target[len("vm:"):]
|
||||
if self.board is None or name not in self.board.lines:
|
||||
return await self.reply("NO CARRIER")
|
||||
got = await self.board.call(name)
|
||||
if got is None:
|
||||
return await self.reply("BUSY")
|
||||
# ponytail: CONNECT lands as soon as the callee starts RINGing,
|
||||
# not when it answers -- parity with the TCP path, where the
|
||||
# kernel completes connect() before anyone picks up. Data just
|
||||
# buffers until the callee's ATA. Wait on an answer event if a
|
||||
# guest ever objects.
|
||||
self.peer = (got[0], got[1], got[1].close)
|
||||
elif target.startswith("ssh:"):
|
||||
# Same subprocess shape as ppp. Deliberately NOT a generic
|
||||
# "exec:" target: a phonebook is the kind of file that ends up
|
||||
# editable by something other than root, and arbitrary argv in
|
||||
# it would be remote root execution wearing a phone number.
|
||||
dest = target[len("ssh:"):]
|
||||
host, sep, port = dest.rpartition(":")
|
||||
cmd = ["ssh", "-tt"] + (["-p", port] if sep else []) + [host if sep else dest]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self.peer = (proc.stdout, proc.stdin, proc.kill)
|
||||
elif target == "ppp":
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*self.pppd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self.peer = (proc.stdout, proc.stdin, proc.kill)
|
||||
elif target.startswith("telnet:"):
|
||||
addr = target[len("telnet:"):]
|
||||
host, sep, port = addr.rpartition(":")
|
||||
if not sep: # "telnet:bbs.example.com" -- the default port is the point
|
||||
host, port = addr, "23"
|
||||
r, w = await asyncio.open_connection(host, int(port))
|
||||
tn = Telnet(r, w)
|
||||
self.peer = (tn, tn, w.close)
|
||||
else:
|
||||
host, _, port = target.rpartition(":")
|
||||
r, w = await asyncio.open_connection(host, int(port))
|
||||
self.peer = (r, w, w.close)
|
||||
except ConnectionRefusedError:
|
||||
# The far end is engaged: a busy line closes its listener, so the
|
||||
# refusal at connect() IS the busy tone. Must be caught before
|
||||
# OSError, which it subclasses.
|
||||
return await self.reply("BUSY")
|
||||
except (OSError, ValueError):
|
||||
return await self.reply("NO CARRIER")
|
||||
await self._hook()
|
||||
await self.reply("CONNECT")
|
||||
await self.online()
|
||||
|
||||
async def _hook(self):
|
||||
"""Tell the owner whether the line is engaged. A ringing line counts as
|
||||
engaged, same as a real one -- you do not get two calls on one pair."""
|
||||
if self.hook:
|
||||
await self.hook()
|
||||
|
||||
async def ring(self, r, w):
|
||||
"""An inbound call. Ring the DTE until it answers or the caller tires."""
|
||||
call = self.incoming = (r, w, w.close)
|
||||
await self._hook()
|
||||
for n in range(1, RINGS + 1):
|
||||
if self.incoming is not call:
|
||||
return # answered, or hung up on
|
||||
await self.reply("RING")
|
||||
if self.s.get(0) and n >= self.s[0]:
|
||||
self.autoanswer.set()
|
||||
return
|
||||
await asyncio.sleep(self.ring_gap)
|
||||
if self.incoming is call: # nobody picked up
|
||||
self.incoming = None
|
||||
w.close()
|
||||
await self._hook()
|
||||
|
||||
async def answer(self):
|
||||
if not self.incoming:
|
||||
return await self.reply("NO CARRIER")
|
||||
self.peer, self.incoming = self.incoming, None
|
||||
await self.reply("CONNECT")
|
||||
await self.online()
|
||||
|
||||
async def hangup(self):
|
||||
# ATH while ringing rejects the call, which is also how a real one behaves.
|
||||
for slot in ("peer", "incoming"):
|
||||
call = getattr(self, slot)
|
||||
if call:
|
||||
try:
|
||||
call[2]()
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
setattr(self, slot, None)
|
||||
await self._hook()
|
||||
|
||||
# -- data mode ---------------------------------------------------------
|
||||
|
||||
async def online(self):
|
||||
"""Pump both directions until +++ escapes or the far end drops.
|
||||
|
||||
Returns to command mode with the call still up on escape (ATO resumes,
|
||||
ATH hangs up), exactly as a real modem does.
|
||||
"""
|
||||
peer_r, peer_w, _ = self.peer
|
||||
up = asyncio.ensure_future(self._dte_to_peer(peer_w))
|
||||
down = asyncio.ensure_future(self._peer_to_dte(peer_r))
|
||||
# Whichever direction ends first ends the call: waiting only on the DTE
|
||||
# side left the modem pumping into a dead peer after NO CARRIER, so it
|
||||
# never came back to command mode and swallowed every later command.
|
||||
done, pending = await asyncio.wait(
|
||||
{up, down}, return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if up in done and up.result(): # +++ : the call stays up, ATO resumes it
|
||||
await self.reply("OK")
|
||||
|
||||
async def _dte_to_peer(self, peer_w):
|
||||
"""Guest -> far end. True if the guest escaped with +++."""
|
||||
held, last = b"", time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
self.dte_r.read(4096), self.guard if held else None)
|
||||
except asyncio.TimeoutError:
|
||||
return True # trailing guard elapsed: escaped
|
||||
if not data:
|
||||
return False
|
||||
if held: # +++ was followed by traffic, so it was just data
|
||||
peer_w.write(held)
|
||||
held = b""
|
||||
if data.startswith(b"+++") and time.monotonic() - last >= self.guard:
|
||||
# NT4's RAS sends the escape and the command as ONE write --
|
||||
# "+++ATH" -- so matching only a bare b"+++" forwarded the whole
|
||||
# thing to the far end and the line could never be hung up.
|
||||
# A bare +++ still needs its trailing guard time; +++ followed by
|
||||
# a command is unambiguous, so escape at once and hand the rest
|
||||
# to the command reader.
|
||||
# ponytail: still assumes +++ arrives in one read. A dialer that
|
||||
# dribbles it byte-by-byte needs a per-byte timer instead.
|
||||
if len(data) == 3:
|
||||
held = data
|
||||
continue
|
||||
self.pushback = data[3:]
|
||||
return True
|
||||
try:
|
||||
peer_w.write(data)
|
||||
await peer_w.drain()
|
||||
except (ConnectionError, BrokenPipeError):
|
||||
return False
|
||||
last = time.monotonic()
|
||||
|
||||
async def _peer_to_dte(self, peer_r):
|
||||
"""Far end -> guest, until carrier drops."""
|
||||
while True:
|
||||
data = await peer_r.read(4096)
|
||||
if not data:
|
||||
break
|
||||
self.dte_w.write(data)
|
||||
await self.dte_w.drain()
|
||||
await self.hangup()
|
||||
await self.reply("NO CARRIER")
|
||||
|
||||
async def run(self):
|
||||
while True:
|
||||
line = await self.read_command()
|
||||
if line is None:
|
||||
return await self.hangup()
|
||||
await self.command(line)
|
||||
|
||||
|
||||
class Switchboard:
|
||||
"""The lines this process owns, so a call between two of them never leaves it.
|
||||
|
||||
Dialling a VM cannot be "just another target type": the answering guest needs
|
||||
a MODEM to hear RING and reply ATA, so wiring the caller straight to its
|
||||
serial socket would hand RAS raw bytes and it would never pick up. Only a
|
||||
process holding both ends can ring one on behalf of the other -- which is
|
||||
also where the hunt group and a real busy signal come from.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.lines = {} # name -> Modem, while attached
|
||||
|
||||
def free(self, name):
|
||||
modem = self.lines.get(name)
|
||||
return modem is not None and not modem.peer and not modem.incoming
|
||||
|
||||
async def call(self, name):
|
||||
"""Ring line `name`. Returns the caller's end, or None if it is engaged.
|
||||
|
||||
A socketpair is the whole implementation: each modem gets one end and
|
||||
every path below -- the pump, 8-bit cleanliness, +++, NO CARRIER -- is
|
||||
the same code that carries an external call.
|
||||
"""
|
||||
if not self.free(name):
|
||||
return None
|
||||
left, right = socket.socketpair()
|
||||
caller_r, caller_w = await asyncio.open_connection(sock=left)
|
||||
callee_r, callee_w = await asyncio.open_connection(sock=right)
|
||||
asyncio.ensure_future(self.lines[name].ring(callee_r, callee_w))
|
||||
return caller_r, caller_w
|
||||
|
||||
|
||||
async def connect_dte(target):
|
||||
"""Attach to a DTE that is listening: a unix socket path, or host:port."""
|
||||
if ":" in target:
|
||||
host, _, port = target.rpartition(":")
|
||||
return await asyncio.open_connection(host, int(port))
|
||||
return await asyncio.open_unix_connection(target)
|
||||
|
||||
|
||||
async def serve(book, pppd, listen=None, connect=None, line=None, guard=GUARD,
|
||||
ring=RING, board=None, name=None):
|
||||
"""Bring up one modem and, if --line was given, its phone line.
|
||||
|
||||
Returns (dte_server_or_None, phone), where phone["port"] is the line's real
|
||||
port once it has been bound. With a `board`, the modem registers itself so
|
||||
other lines can ring it without going out over TCP.
|
||||
"""
|
||||
state = {}
|
||||
phone = {"port": None if line is None else int(line), "srv": None}
|
||||
|
||||
async def call(r, w):
|
||||
modem = state.get("modem")
|
||||
if modem is None or modem.peer or modem.incoming:
|
||||
return w.close() # lost a race with hook(); refuse politely
|
||||
await modem.ring(r, w)
|
||||
|
||||
async def hook():
|
||||
"""Listen only while a modem is attached and on-hook.
|
||||
|
||||
This is the busy signal. Closing the listener means a second caller is
|
||||
refused by the kernel at connect(), before anything can pretend the call
|
||||
went through -- whereas accepting and then closing would make the caller
|
||||
report CONNECT and immediately NO CARRIER. A ringing line is engaged too.
|
||||
"""
|
||||
if phone["port"] is None:
|
||||
return
|
||||
modem = state.get("modem")
|
||||
free = modem is not None and not modem.peer and not modem.incoming
|
||||
if free and not phone["srv"]:
|
||||
# 0.0.0.0, not "": an unspecified host binds one socket per family,
|
||||
# and with port 0 each gets a *different* ephemeral port. v4-only is
|
||||
# honest here -- retronet has no IPv6 and the laptop's is broken.
|
||||
phone["srv"] = await asyncio.start_server(call, "0.0.0.0", phone["port"])
|
||||
phone["port"] = phone["srv"].sockets[0].getsockname()[1]
|
||||
elif not free and phone["srv"]:
|
||||
phone["srv"].close()
|
||||
phone["srv"] = None
|
||||
|
||||
async def attach(r, w):
|
||||
modem = Modem(r, w, book, pppd, guard, ring, hook)
|
||||
modem.board = board
|
||||
state["modem"] = modem
|
||||
if board is not None:
|
||||
board.lines[name] = modem
|
||||
await hook()
|
||||
try:
|
||||
await modem.run()
|
||||
finally:
|
||||
state.pop("modem", None)
|
||||
if board is not None:
|
||||
board.lines.pop(name, None)
|
||||
await hook()
|
||||
w.close()
|
||||
|
||||
if connect:
|
||||
# PVE's `-serial0 socket` leaves QEMU listening, so for a VM we are the
|
||||
# client. 86Box and plain TCP want the opposite; hence both modes.
|
||||
async def keep_attached():
|
||||
"""Reattach forever: a guest reboot takes the chardev peer with it,
|
||||
and a switchboard that needs restarting after every VM reboot is not
|
||||
a service."""
|
||||
while True:
|
||||
try:
|
||||
r, w = await connect_dte(connect)
|
||||
except OSError as exc:
|
||||
log.warning("line %s: %s", name or connect, exc)
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
log.info("line %s attached", name or connect)
|
||||
await attach(r, w)
|
||||
log.info("line %s dropped, reattaching", name or connect)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
asyncio.ensure_future(keep_attached())
|
||||
return None, phone
|
||||
|
||||
if ":" in listen:
|
||||
host, _, port = listen.rpartition(":")
|
||||
dte = await asyncio.start_server(attach, host or "127.0.0.1", int(port))
|
||||
else:
|
||||
dte = await asyncio.start_unix_server(attach, listen)
|
||||
return dte, phone
|
||||
|
||||
|
||||
async def selftest():
|
||||
"""Covers the three things that are easy to get wrong: the link must be
|
||||
8-bit clean (what ip232 fails, and the reason this file exists), &D2 must
|
||||
not read as a dial command, and an inbound call must ring and be answerable
|
||||
both ways -- ATA, and S0 auto-answer."""
|
||||
# start_server only schedules the callback if it is a coroutine FUNCTION --
|
||||
# a lambda returning a coroutine is silently dropped. Pass _echo itself.
|
||||
echo = await asyncio.start_server(_echo, "127.0.0.1", 0)
|
||||
port = echo.sockets[0].getsockname()[1]
|
||||
dead = _closed_port() # nothing listening: dialling it must give BUSY
|
||||
book = {"5551212": f"127.0.0.1:{port}", "5559999": f"127.0.0.1:{dead}"}
|
||||
dte, phone = await serve(book, [], listen="127.0.0.1:0", line=0,
|
||||
guard=0.05, ring=0.05)
|
||||
r, w = await asyncio.open_connection(*dte.sockets[0].getsockname()[:2])
|
||||
|
||||
# unknown setup commands must not be mistaken for a dial (&D2!)
|
||||
assert parse("&F&C1&D2S0=0") == [("&F", ""), ("&C", "1"), ("&D", "2"), ("S0", "0")]
|
||||
assert parse("DT9,1-555-1212") == [("D", "T9,1-555-1212")]
|
||||
assert digits("9,1-555-1212") == "915551212"
|
||||
|
||||
w.write(b"AT&F&C1&D2S0=0\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
line = phone["port"] # only bound once a modem attached, which OK just proved
|
||||
assert line, "line never opened"
|
||||
|
||||
w.write(b"ATDT555-1212\r")
|
||||
await w.drain()
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
||||
|
||||
payload = bytes(range(256)) * 4 # 0xFF and FF 03 included, deliberately
|
||||
w.write(payload)
|
||||
await w.drain()
|
||||
assert await r.readexactly(len(payload)) == payload, "link is not 8-bit clean"
|
||||
|
||||
await asyncio.sleep(0.2) # leading guard
|
||||
w.write(b"+++")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n"), "+++ did not escape"
|
||||
w.write(b"ATH\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
|
||||
# the real Win98 dial sequence: init string, then a staged dial
|
||||
w.write(b"ATE0V1&C1&D2S0=0\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n"), "Win98 init string rejected"
|
||||
w.write(b"ATDT;\r") # TAPI opens the call with no digits at all
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n"), "ATDT; must be OK, not NO CARRIER"
|
||||
w.write(b"ATDT555-1212\r") # ...and only then dials
|
||||
await w.drain()
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
||||
await asyncio.sleep(0.2) # data mode now: escape before ATH is a command
|
||||
w.write(b"+++")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
w.write(b"ATH\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
|
||||
# "+++ATH" in one write is how NT4 RAS hangs up: escape, then the command
|
||||
w.write(b"ATDT555-1212\r")
|
||||
await w.drain()
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
||||
await asyncio.sleep(0.2)
|
||||
w.write(b"+++ATH\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n"), "+++ATH did not escape"
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n"), "ATH after +++ was not obeyed"
|
||||
|
||||
# an address dialled straight, with no phonebook entry: the T modifier
|
||||
# must not end up inside the hostname
|
||||
w.write(f"ATDT127.0.0.1:{port}\r".encode())
|
||||
await w.drain()
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n"), "direct address dial failed"
|
||||
await asyncio.sleep(0.2)
|
||||
w.write(b"+++ATH\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
|
||||
# telnet: mode must swallow the IAC handshake and answer it, so the guest
|
||||
# sees only the prompt -- and must double 0xFF on the way out
|
||||
seen = []
|
||||
|
||||
async def fake_telnetd(tr, tw):
|
||||
tw.write(b"\xff\xfb\x01\xff\xfb\x03login: ") # WILL ECHO, WILL SGA
|
||||
await tw.drain()
|
||||
while (d := await tr.read(100)):
|
||||
seen.append(d)
|
||||
|
||||
td = await asyncio.start_server(fake_telnetd, "127.0.0.1", 0)
|
||||
book["5552323"] = f"telnet:127.0.0.1:{td.sockets[0].getsockname()[1]}"
|
||||
w.write(b"ATDT5552323\r")
|
||||
await w.drain()
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
||||
assert await r.readexactly(7) == b"login: ", "IAC negotiation reached the guest"
|
||||
w.write(b"\xff\x01") # guest sends a literal 0xFF
|
||||
await w.drain()
|
||||
await asyncio.sleep(0.3)
|
||||
got = b"".join(seen)
|
||||
assert got.startswith(b"\xff\xfd\x01\xff\xfd\x03"), f"no DO ECHO / DO SGA: {got}"
|
||||
assert got.endswith(b"\xff\xff\x01"), f"0xFF was not doubled: {got}"
|
||||
await asyncio.sleep(0.2)
|
||||
w.write(b"+++ATH\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
|
||||
# dialling a line with nobody on it is BUSY, not NO CARRIER
|
||||
w.write(b"ATDT5559999\r")
|
||||
await w.drain()
|
||||
assert b"BUSY" in await r.readuntil(b"BUSY\r\n")
|
||||
|
||||
# inbound, answered by hand -- NT4 RAS / TAPI issue ATA on RING
|
||||
cr, cw = await asyncio.open_connection("127.0.0.1", line)
|
||||
assert b"RING" in await r.readuntil(b"RING\r\n")
|
||||
|
||||
# ...and while it rings, the line is engaged: the second caller is refused
|
||||
# by the kernel, so its modem reports BUSY instead of a phantom CONNECT
|
||||
try:
|
||||
await asyncio.open_connection("127.0.0.1", line)
|
||||
raise AssertionError("second caller was not given a busy line")
|
||||
except ConnectionRefusedError:
|
||||
pass
|
||||
|
||||
w.write(b"ATA\r")
|
||||
await w.drain()
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n")
|
||||
cw.write(b"\xff\x03hello") # a PPP-shaped frame, inbound this time
|
||||
await cw.drain()
|
||||
assert await r.readexactly(7) == b"\xff\x03hello"
|
||||
cw.close()
|
||||
assert b"NO CARRIER" in await r.readuntil(b"NO CARRIER\r\n")
|
||||
|
||||
# inbound, auto-answered: the guest sets S0 and then says nothing at all
|
||||
w.write(b"ATS0=2\r")
|
||||
await w.drain()
|
||||
assert b"OK" in await r.readuntil(b"OK\r\n")
|
||||
cr, cw = await asyncio.open_connection("127.0.0.1", line)
|
||||
assert b"CONNECT" in await r.readuntil(b"CONNECT\r\n"), "S0 did not auto-answer"
|
||||
# switchboard: two lines in one process, an internal call between them
|
||||
tmp = tempfile.mkdtemp()
|
||||
ends = {}
|
||||
|
||||
async def fake_qemu(nm): # stands in for QEMU's listening serial socket
|
||||
async def cb(qr, qw):
|
||||
ends[nm] = (qr, qw)
|
||||
await asyncio.start_unix_server(cb, f"{tmp}/{nm}.sock")
|
||||
|
||||
await fake_qemu("A")
|
||||
await fake_qemu("B")
|
||||
board, book2 = Switchboard(), {"5551111": "vm:B", "5552222": "vm:NOPE"}
|
||||
for nm in ("A", "B"):
|
||||
await serve(book2, [], connect=f"{tmp}/{nm}.sock", board=board, name=nm,
|
||||
guard=0.05, ring=0.05)
|
||||
for _ in range(60):
|
||||
if len(board.lines) == 2 and len(ends) == 2:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(board.lines) == 2, f"lines did not attach: {board.lines}"
|
||||
ar, aw = ends["A"]
|
||||
br, bw = ends["B"]
|
||||
|
||||
aw.write(b"ATDT5551111\r")
|
||||
await aw.drain()
|
||||
assert b"RING" in await br.readuntil(b"RING\r\n"), "callee never rang"
|
||||
bw.write(b"ATA\r")
|
||||
await bw.drain()
|
||||
assert b"CONNECT" in await ar.readuntil(b"CONNECT\r\n")
|
||||
assert b"CONNECT" in await br.readuntil(b"CONNECT\r\n")
|
||||
both = bytes(range(256)) # the internal hop must be 8-bit clean too
|
||||
aw.write(both)
|
||||
await aw.drain()
|
||||
assert await br.readexactly(256) == both, "internal call is not 8-bit clean"
|
||||
|
||||
# an unknown line is NO CARRIER; an engaged one is BUSY
|
||||
await asyncio.sleep(0.2)
|
||||
bw.write(b"+++")
|
||||
await bw.drain()
|
||||
assert b"OK" in await br.readuntil(b"OK\r\n")
|
||||
bw.write(b"ATDT5552222\r")
|
||||
await bw.drain()
|
||||
assert b"NO CARRIER" in await br.readuntil(b"NO CARRIER\r\n"), "unknown line"
|
||||
bw.write(b"ATDT5551111\r") # line B calling... a line that is engaged (itself)
|
||||
await bw.drain()
|
||||
assert b"BUSY" in await br.readuntil(b"BUSY\r\n"), "engaged line was not BUSY"
|
||||
|
||||
print("ok")
|
||||
|
||||
|
||||
def _closed_port():
|
||||
"""A port the OS just handed back, so nothing is listening on it."""
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
async def _echo(r, w):
|
||||
while (data := await r.read(4096)):
|
||||
w.write(data)
|
||||
await w.drain()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
dte = ap.add_mutually_exclusive_group()
|
||||
dte.add_argument("--listen", help="unix socket path, or host:port, to listen on")
|
||||
dte.add_argument("--connect", help="unix socket path, or host:port, to dial into "
|
||||
"(PVE's -serial0 socket leaves QEMU listening)")
|
||||
ap.add_argument("--phonebook", help="86Box-format number->target map")
|
||||
ap.add_argument("--pppd", default="", help="command run for a 'ppp' target")
|
||||
ap.add_argument("--line", help="TCP port to accept incoming calls on")
|
||||
ap.add_argument("--vm", action="append", default=[], metavar="VMID[:PORT]",
|
||||
help="switchboard line on a PVE VM's serial0 socket; repeatable. "
|
||||
"PORT accepts calls from off-box (86Box on another host); "
|
||||
"other lines reach it as the phonebook target vm:VMID")
|
||||
ap.add_argument("--debug", action="store_true", help="log the AT conversation")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
a = ap.parse_args()
|
||||
logging.basicConfig(level=logging.INFO if a.debug else logging.WARNING,
|
||||
format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
|
||||
if a.selftest:
|
||||
return asyncio.run(selftest())
|
||||
if not (a.listen or a.connect or a.vm):
|
||||
ap.error("one of --listen, --connect or --vm is required")
|
||||
asyncio.run(_run(a))
|
||||
|
||||
|
||||
async def _run(a):
|
||||
book, pppd = Phonebook(a.phonebook), shlex.split(a.pppd)
|
||||
if a.vm:
|
||||
board = Switchboard()
|
||||
for spec in a.vm:
|
||||
vmid, _, port = spec.partition(":")
|
||||
await serve(book, pppd, board=board, name=vmid, line=port or None,
|
||||
connect=f"/var/run/qemu-server/{vmid}.serial0")
|
||||
log.info("line %s%s", vmid, f" answering on :{port}" if port else "")
|
||||
if a.listen or a.connect:
|
||||
await serve(book, pppd, listen=a.listen, connect=a.connect, line=a.line)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# A bare Linux bridge with one physical port and NO IP address, used as the
|
||||
# attach point for emulator TAP interfaces (86Box, PCem, qemu, ...).
|
||||
#
|
||||
# WHY a bridge at all: 86Box's TAP backend creates its own tap device
|
||||
# (TUNSETIFF) and then enslaves it to a bridge you name (SIOCBRADDIF). It does
|
||||
# NOT bridge to a raw NIC, so the NIC has to already be in a bridge for the
|
||||
# emulated guests to reach the wire.
|
||||
#
|
||||
# WHY no IP on the bridge: the host deliberately does not sit on the retro
|
||||
# segment. Guests use the VyOS SDN gateway (10.61.0.1) directly. Giving the
|
||||
# host an address here would put an up-to-date Linux box inside the retro
|
||||
# broadcast domain for no reason, and would make the retro guests able to
|
||||
# reach this host's services.
|
||||
tap_bridge_name: br0
|
||||
|
||||
# Matched by MAC, not by name: the kernel's predictable-interface name depends
|
||||
# on PCI slot ordering, which moves if the VM's NIC layout changes. The MACs
|
||||
# are pinned in pve_vm defaults precisely so they can be matched on.
|
||||
tap_bridge_member: ""
|
||||
tap_bridge_member_mac: ""
|
||||
|
||||
tap_bridge_netplan_file: /etc/netplan/60-tap-bridge.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
- name: Apply netplan
|
||||
# `netplan apply` re-applies EVERY netplan file, including the cloud-init one
|
||||
# that owns the management NIC this play is connected over. It does not
|
||||
# normally bounce an unchanged interface, but if a run ever hangs here that
|
||||
# is the reason -- check the console via `qm terminal 101`.
|
||||
ansible.builtin.command:
|
||||
cmd: netplan apply
|
||||
changed_when: true
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
- name: Check the bridge role is configured
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- tap_bridge_member | length > 0
|
||||
- tap_bridge_member_mac | length > 0
|
||||
fail_msg: "tap_bridge_member and tap_bridge_member_mac must be set"
|
||||
|
||||
- name: Install bridge utilities
|
||||
# bridge-utils is not needed by netplan (it uses netlink), but brctl is the
|
||||
# quickest way to see which taps an emulator has attached while debugging.
|
||||
ansible.builtin.apt:
|
||||
name: bridge-utils
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
register: _brutils
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _brutils is succeeded
|
||||
|
||||
- name: Configure the bridge in netplan
|
||||
ansible.builtin.template:
|
||||
src: netplan.yaml.j2
|
||||
dest: "{{ tap_bridge_netplan_file }}"
|
||||
# 0600: netplan warns loudly about world-readable config and ignores such
|
||||
# files in newer releases.
|
||||
mode: "0600"
|
||||
owner: root
|
||||
group: root
|
||||
notify: Apply netplan
|
||||
|
||||
- name: Flush handlers so the bridge exists before it is verified
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Read back the bridge state
|
||||
ansible.builtin.command:
|
||||
cmd: "ip -br link show {{ tap_bridge_name }}"
|
||||
register: _br
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Read back the enslaved port
|
||||
# `master <bridge>` in `ip link` output is the authoritative proof that the
|
||||
# NIC is actually in the bridge -- netplan reporting success is not.
|
||||
ansible.builtin.shell:
|
||||
cmd: "ip -o link show {{ tap_bridge_member }} | grep -o 'master [^ ]*' || true"
|
||||
register: _member
|
||||
changed_when: false
|
||||
|
||||
- name: Verify the bridge is up with the port enslaved
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _br.rc == 0
|
||||
- "'master ' ~ tap_bridge_name in _member.stdout"
|
||||
fail_msg: >-
|
||||
Bridge {{ tap_bridge_name }} is not correctly set up.
|
||||
bridge: {{ _br.stdout | default('absent') }} / port: {{ _member.stdout | default('none') }}
|
||||
success_msg: "{{ tap_bridge_name }} up, {{ tap_bridge_member }} enslaved"
|
||||
@@ -0,0 +1,33 @@
|
||||
# {{ ansible_managed }}
|
||||
# Bridge for emulator TAP interfaces. Separate from 50-cloud-init.yaml on
|
||||
# purpose: cloud-init rewrites its own file, and it only claims the primary
|
||||
# NIC (matched by MAC), so this file can own the second NIC without conflict.
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
{{ tap_bridge_member }}:
|
||||
match:
|
||||
macaddress: "{{ tap_bridge_member_mac }}"
|
||||
set-name: {{ tap_bridge_member }}
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
accept-ra: false
|
||||
# optional: do not let systemd-networkd-wait-online block boot for a port
|
||||
# that has no L3 config and may have nothing plugged behind it.
|
||||
optional: true
|
||||
bridges:
|
||||
{{ tap_bridge_name }}:
|
||||
interfaces: [{{ tap_bridge_member }}]
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
accept-ra: false
|
||||
optional: true
|
||||
parameters:
|
||||
# STP off + zero forward delay: taps appear and disappear every time an
|
||||
# emulated machine is powered on. With STP the bridge would hold each
|
||||
# new port in learning state for ~15s and silently drop the guest's
|
||||
# first packets, which looks exactly like a broken guest TCP/IP stack.
|
||||
# Safe here because this bridge has a single uplink -- no loop is
|
||||
# possible.
|
||||
stp: false
|
||||
forward-delay: 0
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
# VyOS router VM (VM 100) — gateway for the PVE SDN VNets, OSPF peer to the NEC IX.
|
||||
#
|
||||
# HOW THIS IS MANAGED — and why not the obvious way:
|
||||
# We drive `vyos.vyos.vyos_config` with explicit `set` lines from a template,
|
||||
# NOT the collection's resource modules (vyos_interfaces / vyos_ospfv2 /
|
||||
# vyos_firewall_rules). Reason, verified 2026-07-25 on this box:
|
||||
# * VyOS 2025.11 REJECTS the old syntax: `set firewall name X ...` ->
|
||||
# "Configuration path: firewall [name] is not valid"
|
||||
# * vyos.vyos 6.0.0 still EMITS `firewall name` (grepped the module_utils)
|
||||
# So the resource modules would generate config this release cannot parse. On a
|
||||
# Stream/tech-preview build the safe move is to own the syntax ourselves;
|
||||
# vyos_config still gives idempotency by diffing against the running config.
|
||||
|
||||
vyos_router_hostname: vyos-rtr
|
||||
|
||||
# LAN leg — OSPF adjacency with the NEC IX (and the laptop, which is also an
|
||||
# OSPF speaker on this segment).
|
||||
vyos_lan_interface: eth0
|
||||
vyos_lan_address: "192.168.10.2/24"
|
||||
vyos_lan_gateway: "192.168.10.1"
|
||||
|
||||
vyos_nameserver: "192.168.10.5"
|
||||
vyos_dhcp_nameserver: "192.168.10.5" # the Samba DC — see the flaky-WAN notes
|
||||
|
||||
# SDN legs. `vnet` is the PVE VNet the NIC is attached to; `passive` keeps OSPF
|
||||
# from trying to form adjacencies with guests on these segments.
|
||||
# `dhcp.subnet_id` MUST be unique across the whole dhcp-server config (VyOS 1.4+
|
||||
# requires it explicitly). Ranges deliberately start at .100 so .2-.99 stay free
|
||||
# for anything that wants a static address inside a VNet.
|
||||
vyos_sdn_interfaces:
|
||||
- iface: eth1
|
||||
vnet: labnet
|
||||
address: "10.60.0.1/24"
|
||||
network: "10.60.0.0/24"
|
||||
description: "labnet VLAN100 gateway"
|
||||
dhcp:
|
||||
subnet_id: 1
|
||||
start: "10.60.0.100"
|
||||
stop: "10.60.0.200"
|
||||
domain: "ad.ddupan.top"
|
||||
# Reservations sit BELOW the .100 pool start so they never collide with it.
|
||||
# Keyed on the VM's pinned MAC (see proxmox/ansible/roles/pve_vm).
|
||||
reservations:
|
||||
- { name: retrolab, mac: "bc:24:11:68:a0:51", address: "10.60.0.10" }
|
||||
- iface: eth2
|
||||
vnet: retronet
|
||||
address: "10.61.0.1/24"
|
||||
network: "10.61.0.0/24"
|
||||
description: "retronet VLAN110 gateway"
|
||||
dhcp:
|
||||
subnet_id: 2
|
||||
start: "10.61.0.100"
|
||||
stop: "10.61.0.200"
|
||||
domain: "ad.ddupan.top"
|
||||
# Retro Windows (9x/NT/2000) resolves names via NetBIOS, not DNS, so the
|
||||
# segment needs a WINS server -- this is the whole reason retronet exists.
|
||||
# Was 192.168.10.5 (the Samba DC); now retro-pdc, which is the PDC of the
|
||||
# RETRONET domain, so browser elections and domain logons resolve on-segment
|
||||
# and retronet keeps no dependency on the production DC.
|
||||
# PRECONDITION: the WINS service must actually be installed and running on
|
||||
# retro-pdc -- an unanswering wins-server option is worse than the old one.
|
||||
wins: "10.61.0.5"
|
||||
|
||||
# OSPF. The SDN subnets are declared as INTRA-AREA networks, deliberately.
|
||||
# Do NOT switch this to `redistribute connected`: that advertises every connected
|
||||
# interface (so any future NIC leaks automatically) and injects E2 routes whose
|
||||
# metric does not accumulate path cost. Verified on the IX: the same prefix went
|
||||
# from `O E2 ... [110/20]` to `O ... [110/2]` after this change.
|
||||
vyos_ospf_router_id: "192.168.10.2"
|
||||
vyos_ospf_area: "0"
|
||||
vyos_ospf_networks:
|
||||
- "192.168.10.0/24"
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
# Apply the desired VyOS configuration. `vyos_config` compares the rendered
|
||||
# `set` lines against the running config and issues only the differences, so
|
||||
# re-runs are no-ops. Supports --check and --diff.
|
||||
|
||||
- name: Render the desired configuration
|
||||
ansible.builtin.set_fact:
|
||||
_vyos_lines: >-
|
||||
{{ lookup('template', 'vyos.conf.j2').splitlines()
|
||||
| map('trim') | reject('equalto', '') | list }}
|
||||
|
||||
- name: Apply configuration
|
||||
vyos.vyos.vyos_config:
|
||||
lines: "{{ _vyos_lines }}"
|
||||
# Persist to config.boot; without this the config is lost on reboot.
|
||||
save: true
|
||||
# Pull the PRE-change running config back to the control host. VyOS also
|
||||
# keeps its own commit revisions (`show system commit`, `rollback N`), but
|
||||
# those are only reachable if the box is still reachable -- which is exactly
|
||||
# what a bad change takes away.
|
||||
backup: true
|
||||
backup_options:
|
||||
dir_path: "{{ playbook_dir }}/../vyos/backups"
|
||||
filename: "config.boot"
|
||||
comment: "ansible {{ lookup('pipe', 'date -u +%Y-%m-%dT%H:%M:%SZ') }}"
|
||||
register: _vyos_cfg
|
||||
# `backup: true` fetches the running config every run, which the module counts
|
||||
# as a change. Report changed ONLY when commands were actually issued,
|
||||
# otherwise real drift is indistinguishable from a routine backup.
|
||||
changed_when: (_vyos_cfg.commands | default([]) | length) > 0
|
||||
|
||||
- name: Show what changed
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _vyos_cfg.commands | default(['(no changes)']) }}"
|
||||
|
||||
# ── post-deploy verification ──────────────────────────────────────────────
|
||||
# Proving the config was WRITTEN is not the same as proving the network still
|
||||
# WORKS. These assert operational state, which is the part a bad change breaks.
|
||||
- name: Collect operational state
|
||||
vyos.vyos.vyos_command:
|
||||
commands:
|
||||
- show ip ospf neighbor
|
||||
- show interfaces
|
||||
register: _vyos_state
|
||||
changed_when: false
|
||||
|
||||
- name: Assert OSPF adjacency with the upstream router is Full
|
||||
ansible.builtin.assert:
|
||||
that: "'Full' in _vyos_state.stdout[0] and vyos_lan_gateway in _vyos_state.stdout[0]"
|
||||
fail_msg: >-
|
||||
No Full OSPF adjacency with {{ vyos_lan_gateway }}. The SDN subnets are
|
||||
NOT being advertised, so nothing can reach them.
|
||||
Neighbors seen:\n{{ _vyos_state.stdout[0] }}
|
||||
success_msg: "OSPF adjacency with {{ vyos_lan_gateway }} is Full"
|
||||
|
||||
- name: Assert each SDN gateway address is actually live
|
||||
ansible.builtin.assert:
|
||||
that: "item.address in _vyos_state.stdout[1]"
|
||||
fail_msg: >-
|
||||
{{ item.iface }} ({{ item.description }}) is missing {{ item.address }} --
|
||||
guests on that VNet have no gateway.
|
||||
quiet: true
|
||||
loop: "{{ vyos_sdn_interfaces }}"
|
||||
loop_control:
|
||||
label: "{{ item.iface }} {{ item.address }}"
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _vyos_state.stdout[0].splitlines() | select('search', 'Full') | list }}"
|
||||
@@ -0,0 +1,50 @@
|
||||
{# Desired VyOS config as `set` lines. vyos_config diffs these against the
|
||||
running config, so only differences are applied. Written for VyOS 2025.11
|
||||
syntax explicitly -- see defaults/main.yml for why we do not use the
|
||||
collection's resource modules. #}
|
||||
set system host-name {{ vyos_router_hostname }}
|
||||
set system name-server {{ vyos_nameserver }}
|
||||
|
||||
{# --- LAN leg: OSPF peer with the NEC IX --- #}
|
||||
set interfaces ethernet {{ vyos_lan_interface }} address {{ vyos_lan_address }}
|
||||
set interfaces ethernet {{ vyos_lan_interface }} description 'LAN / OSPF to NEC IX'
|
||||
|
||||
{# --- SDN legs: gateways for the PVE VNets --- #}
|
||||
{% for i in vyos_sdn_interfaces %}
|
||||
set interfaces ethernet {{ i.iface }} address {{ i.address }}
|
||||
set interfaces ethernet {{ i.iface }} description '{{ i.description }}'
|
||||
{% endfor %}
|
||||
|
||||
{# --- default route out; OSPF carries the rest --- #}
|
||||
set protocols static route 0.0.0.0/0 next-hop {{ vyos_lan_gateway }}
|
||||
|
||||
{# --- OSPF: intra-area, NOT redistribute connected --- #}
|
||||
set protocols ospf parameters router-id {{ vyos_ospf_router_id }}
|
||||
{% for n in vyos_ospf_networks %}
|
||||
set protocols ospf area {{ vyos_ospf_area }} network {{ n }}
|
||||
{% endfor %}
|
||||
{% for i in vyos_sdn_interfaces %}
|
||||
set protocols ospf area {{ vyos_ospf_area }} network {{ i.network }}
|
||||
{# passive: advertise the subnet, but never try to peer with guests on it #}
|
||||
set protocols ospf interface {{ i.iface }} passive
|
||||
{% endfor %}
|
||||
|
||||
{# --- management --- #}
|
||||
set service ssh port 22
|
||||
|
||||
{# --- DHCP for the SDN VNets --- #}
|
||||
{% for i in vyos_sdn_interfaces if i.dhcp is defined %}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} subnet-id {{ i.dhcp.subnet_id }}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} option default-router {{ i.address.split('/')[0] }}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} option name-server {{ vyos_dhcp_nameserver }}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} option domain-name {{ i.dhcp.domain }}
|
||||
{% if i.dhcp.wins is defined %}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} option wins-server {{ i.dhcp.wins }}
|
||||
{% endif %}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} range 0 start {{ i.dhcp.start }}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} range 0 stop {{ i.dhcp.stop }}
|
||||
{% for r in i.dhcp.reservations | default([]) %}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} static-mapping {{ r.name }} mac {{ r.mac }}
|
||||
set service dhcp-server shared-network-name {{ i.vnet | upper }} subnet {{ i.network }} static-mapping {{ r.name }} ip-address {{ r.address }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# PVE SDN (VLAN zone). Separate from site.yml: it defines guest networking, and
|
||||
# applying SDN reloads network config on every node.
|
||||
- name: Proxmox SDN
|
||||
hosts: pve
|
||||
gather_facts: false
|
||||
roles:
|
||||
- pve_sdn
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# Baseline configuration for the 3-node Proxmox cluster.
|
||||
#
|
||||
# ansible-playbook site.yml --check # dry run first
|
||||
# ansible-playbook site.yml
|
||||
# ansible-playbook site.yml --limit pve2
|
||||
# ansible-playbook site.yml -e pve_dist_upgrade=true
|
||||
#
|
||||
# Cluster formation and LINSTOR are deliberately NOT here yet -- this playbook
|
||||
# is safe to re-run against nodes at any time.
|
||||
- name: Baseline Proxmox nodes
|
||||
hosts: pve
|
||||
gather_facts: true
|
||||
# One node at a time. These are cluster members; a mistake applied in parallel
|
||||
# to all three is a mistake with no healthy node left to compare against.
|
||||
serial: 1
|
||||
roles:
|
||||
- pve_network
|
||||
- pve_dns
|
||||
- pve_post_install
|
||||
- pve_ca_trust
|
||||
# Must follow pve_ca_trust: the node has to trust the internal CA before a
|
||||
# cert issued by it is any use, and the CA fetch is the cheaper thing to fail.
|
||||
- role: pve_acme
|
||||
# Tagged so cert work can be re-run or audited on its own:
|
||||
# ansible-playbook site.yml --tags acme
|
||||
tags: [acme]
|
||||
- pve_mail_relay
|
||||
- pve_kernel_params
|
||||
- pve_nfs
|
||||
|
||||
# Deliberately its own play, on ONE node: the role must not be part of the
|
||||
# serial:1 loop above, which would put three instances of the same UI on the LAN.
|
||||
# pvesh proxies to whichever node owns a VM, so one is enough for the cluster.
|
||||
# ansible-playbook site.yml --tags floppy
|
||||
- name: Floppy drive UI
|
||||
hosts: pve1
|
||||
gather_facts: false
|
||||
tags: [floppy]
|
||||
roles:
|
||||
- pve_floppy
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# VM definitions. Creation only — this does not reconcile live VMs.
|
||||
# ansible-playbook vms.yml
|
||||
- name: Proxmox VMs
|
||||
hosts: pve1
|
||||
gather_facts: false
|
||||
roles:
|
||||
- pve_vm
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
# VyOS router configuration.
|
||||
# ansible-playbook vyos.yml --check --diff
|
||||
# ansible-playbook vyos.yml
|
||||
- name: VyOS router
|
||||
hosts: vyos-rtr
|
||||
gather_facts: false
|
||||
roles:
|
||||
- vyos_router
|
||||
@@ -0,0 +1,2 @@
|
||||
pve2: W7gP56-VTY2-UaCN-OgmN-yT1c-jiRg-PPzQVd
|
||||
pve3: Jt95bI-RHQ4-WnBF-TCqB-lj6y-OUEG-6g7I3n
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Generated by LVM2 version 2.03.31(2) (2025-02-27): Sat Jul 25 04:25:33 2026
|
||||
|
||||
contents = "Text Format Volume Group"
|
||||
version = 1
|
||||
|
||||
description = "Created *before* executing '/sbin/vgs --separator : --noheadings --units b --unbuffered --nosuffix --options vg_name,vg_size,vg_free,lv_count'"
|
||||
|
||||
creation_host = "pve2" # Linux pve2 7.0.2-6-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.2-6 (2026-05-20T08:55Z) x86_64
|
||||
creation_time = 1784921133 # Sat Jul 25 04:25:33 2026
|
||||
|
||||
ceph-167b988c-7c90-4898-baf3-d619675acbb2 {
|
||||
id = "mqsI8e-TFKD-erja-LLrp-z9GS-4D4D-s1EftS"
|
||||
seqno = 5
|
||||
format = "lvm2" # informational
|
||||
status = ["RESIZEABLE", "READ", "WRITE"]
|
||||
flags = []
|
||||
extent_size = 8192 # 4 Megabytes
|
||||
max_lv = 0
|
||||
max_pv = 0
|
||||
metadata_copies = 0
|
||||
|
||||
physical_volumes {
|
||||
|
||||
pv0 {
|
||||
id = "icuiQG-cNJ4-VkNz-wk5Y-ilek-Awhh-LpMiMR"
|
||||
device = "/dev/sda" # Hint only
|
||||
|
||||
status = ["ALLOCATABLE"]
|
||||
flags = []
|
||||
dev_size = 1953525168 # 931.513 Gigabytes
|
||||
pe_start = 2048
|
||||
pe_count = 238467 # 931.512 Gigabytes
|
||||
}
|
||||
}
|
||||
|
||||
logical_volumes {
|
||||
|
||||
osd-block-cd522829-87d2-4c5a-b7f1-6954d1550a72 {
|
||||
id = "kMPnPl-AbSn-mnnH-KlXK-xY9R-SIh7-ZEVPE8"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
tags = ["ceph.osd_fsid=cd522829-87d2-4c5a-b7f1-6954d1550a72", "ceph.osd_id=1", "ceph.cluster_fsid=dfea42a0-6089-4a79-ba81-cae3356ac41f", "ceph.cluster_name=ceph", "ceph.crush_device_class=", "ceph.osdspec_affinity=", "ceph.block_device=/dev/ceph-167b988c-7c90-4898-baf3-d619675acbb2/osd-block-cd522829-87d2-4c5a-b7f1-6954d1550a72", "ceph.block_uuid=kMPnPl-AbSn-mnnH-KlXK-xY9R-SIh7-ZEVPE8", "ceph.cephx_lockbox_secret=", "ceph.encrypted=0", "ceph.with_tpm=0", "ceph.vdo=0", "ceph.type=block", "ceph.db_uuid=TGfD0i-n7OW-3Bjp-z4k6-FGSI-D6PO-VfBS1c", "ceph.db_device=/dev/ceph-fa6cef5f-3822-4252-acea-627d43747837/osd-db-0b54061e-dce6-4be7-8be9-295d2a8383be"]
|
||||
creation_time = 1766233980 # 2025-12-20 21:33:00 +0900
|
||||
creation_host = "pve2"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 238467 # 931.512 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 0
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by LVM2 version 2.03.31(2) (2025-02-27): Sat Jul 25 04:25:33 2026
|
||||
|
||||
contents = "Text Format Volume Group"
|
||||
version = 1
|
||||
|
||||
description = "Created *before* executing '/sbin/vgs --separator : --noheadings --units b --unbuffered --nosuffix --options vg_name,vg_size,vg_free,lv_count'"
|
||||
|
||||
creation_host = "pve2" # Linux pve2 7.0.2-6-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.2-6 (2026-05-20T08:55Z) x86_64
|
||||
creation_time = 1784921133 # Sat Jul 25 04:25:33 2026
|
||||
|
||||
pve {
|
||||
id = "Vv2rZ1-WkZM-Qvpx-4dL2-1hRp-xe8c-ey2qJU"
|
||||
seqno = 7
|
||||
format = "lvm2" # informational
|
||||
status = ["RESIZEABLE", "READ", "WRITE"]
|
||||
flags = []
|
||||
extent_size = 8192 # 4 Megabytes
|
||||
max_lv = 0
|
||||
max_pv = 0
|
||||
metadata_copies = 0
|
||||
|
||||
physical_volumes {
|
||||
|
||||
pv0 {
|
||||
id = "W7gP56-VTY2-UaCN-OgmN-yT1c-jiRg-PPzQVd"
|
||||
device = "/dev/nvme0n1p3" # Hint only
|
||||
|
||||
status = ["ALLOCATABLE"]
|
||||
flags = []
|
||||
dev_size = 123729921 # 58.999 Gigabytes
|
||||
pe_start = 2048
|
||||
pe_count = 15103 # 58.9961 Gigabytes
|
||||
}
|
||||
}
|
||||
|
||||
logical_volumes {
|
||||
|
||||
swap {
|
||||
id = "Vq63er-hRX6-tseB-Wl2j-sqeE-3aBd-FaOXuc"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 1856 # 7.25 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
root {
|
||||
id = "BsbZiD-RkOH-oZrv-xS8B-qjzX-JtXn-2jAxwu"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 6383 # 24.9336 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 1856
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data {
|
||||
id = "dd2Ugb-nXPl-8f87-N7pq-D9wq-Llcn-cb5hg7"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "thin-pool"
|
||||
metadata = "data_tmeta"
|
||||
pool = "data_tdata"
|
||||
transaction_id = 0
|
||||
chunk_size = 128 # 64 Kilobytes
|
||||
discards = "passdown"
|
||||
zero_new_blocks = 1
|
||||
}
|
||||
}
|
||||
|
||||
data_tmeta {
|
||||
id = "jnAVce-fi8p-7Nkn-fJ1v-e942-5vdy-20wRWp"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12734
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
lvol0_pmspare {
|
||||
id = "igVgep-HKOE-dKLU-K3x0-qtTh-ckMr-oFh9ia"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920962 # 2026-07-25 04:22:42 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12990
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data_tdata {
|
||||
id = "hvHPTe-d2xI-s9tE-5soa-b27A-Wwx4-E7j7Gq"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920964 # 2026-07-25 04:22:44 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 8239
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by LVM2 version 2.03.31(2) (2025-02-27): Sat Jul 25 04:25:33 2026
|
||||
|
||||
contents = "Text Format Volume Group"
|
||||
version = 1
|
||||
|
||||
description = "Created *after* executing '/sbin/vgs --separator : --noheadings --units b --unbuffered --nosuffix --options vg_name,vg_size,vg_free,lv_count'"
|
||||
|
||||
creation_host = "pve2" # Linux pve2 7.0.2-6-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.2-6 (2026-05-20T08:55Z) x86_64
|
||||
creation_time = 1784921133 # Sat Jul 25 04:25:33 2026
|
||||
|
||||
pve {
|
||||
id = "Vv2rZ1-WkZM-Qvpx-4dL2-1hRp-xe8c-ey2qJU"
|
||||
seqno = 7
|
||||
format = "lvm2" # informational
|
||||
status = ["RESIZEABLE", "READ", "WRITE"]
|
||||
flags = []
|
||||
extent_size = 8192 # 4 Megabytes
|
||||
max_lv = 0
|
||||
max_pv = 0
|
||||
metadata_copies = 0
|
||||
|
||||
physical_volumes {
|
||||
|
||||
pv0 {
|
||||
id = "W7gP56-VTY2-UaCN-OgmN-yT1c-jiRg-PPzQVd"
|
||||
device = "/dev/nvme0n1p3" # Hint only
|
||||
|
||||
status = ["ALLOCATABLE"]
|
||||
flags = []
|
||||
dev_size = 123729921 # 58.999 Gigabytes
|
||||
pe_start = 2048
|
||||
pe_count = 15103 # 58.9961 Gigabytes
|
||||
}
|
||||
}
|
||||
|
||||
logical_volumes {
|
||||
|
||||
swap {
|
||||
id = "Vq63er-hRX6-tseB-Wl2j-sqeE-3aBd-FaOXuc"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 1856 # 7.25 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
root {
|
||||
id = "BsbZiD-RkOH-oZrv-xS8B-qjzX-JtXn-2jAxwu"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 6383 # 24.9336 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 1856
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data {
|
||||
id = "dd2Ugb-nXPl-8f87-N7pq-D9wq-Llcn-cb5hg7"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "thin-pool"
|
||||
metadata = "data_tmeta"
|
||||
pool = "data_tdata"
|
||||
transaction_id = 0
|
||||
chunk_size = 128 # 64 Kilobytes
|
||||
discards = "passdown"
|
||||
zero_new_blocks = 1
|
||||
}
|
||||
}
|
||||
|
||||
data_tmeta {
|
||||
id = "jnAVce-fi8p-7Nkn-fJ1v-e942-5vdy-20wRWp"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920960 # 2026-07-25 04:22:40 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12734
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
lvol0_pmspare {
|
||||
id = "igVgep-HKOE-dKLU-K3x0-qtTh-ckMr-oFh9ia"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920962 # 2026-07-25 04:22:42 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12990
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data_tdata {
|
||||
id = "hvHPTe-d2xI-s9tE-5soa-b27A-Wwx4-E7j7Gq"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920964 # 2026-07-25 04:22:44 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 8239
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Generated by LVM2 version 2.03.31(2) (2025-02-27): Sat Jul 25 04:15:39 2026
|
||||
|
||||
contents = "Text Format Volume Group"
|
||||
version = 1
|
||||
|
||||
description = "Created *before* executing '/sbin/vgs --separator : --noheadings --units b --unbuffered --nosuffix --options vg_name,vg_size,vg_free,lv_count'"
|
||||
|
||||
creation_host = "pve3" # Linux pve3 7.0.2-6-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.2-6 (2026-05-20T08:55Z) x86_64
|
||||
creation_time = 1784920539 # Sat Jul 25 04:15:39 2026
|
||||
|
||||
ceph-c7860953-e6b0-401c-81a8-9271c4920b45 {
|
||||
id = "Khv4bE-npJw-cNXq-OcjO-ltkd-pZuu-ohJNOF"
|
||||
seqno = 5
|
||||
format = "lvm2" # informational
|
||||
status = ["RESIZEABLE", "READ", "WRITE"]
|
||||
flags = []
|
||||
extent_size = 8192 # 4 Megabytes
|
||||
max_lv = 0
|
||||
max_pv = 0
|
||||
metadata_copies = 0
|
||||
|
||||
physical_volumes {
|
||||
|
||||
pv0 {
|
||||
id = "dF1pQ9-Twe9-Akdq-F0Vy-7uZv-mptx-n1JUw1"
|
||||
device = "/dev/sda" # Hint only
|
||||
|
||||
status = ["ALLOCATABLE"]
|
||||
flags = []
|
||||
dev_size = 1953525168 # 931.513 Gigabytes
|
||||
pe_start = 2048
|
||||
pe_count = 238467 # 931.512 Gigabytes
|
||||
}
|
||||
}
|
||||
|
||||
logical_volumes {
|
||||
|
||||
osd-block-ac905dac-06ab-4cc9-b0d7-56bbf4650f8e {
|
||||
id = "70eZh0-oi4f-58Bt-jLne-xG4k-c3oQ-eTbdGt"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
tags = ["ceph.osd_fsid=ac905dac-06ab-4cc9-b0d7-56bbf4650f8e", "ceph.osd_id=2", "ceph.cluster_fsid=dfea42a0-6089-4a79-ba81-cae3356ac41f", "ceph.cluster_name=ceph", "ceph.crush_device_class=", "ceph.osdspec_affinity=", "ceph.block_device=/dev/ceph-c7860953-e6b0-401c-81a8-9271c4920b45/osd-block-ac905dac-06ab-4cc9-b0d7-56bbf4650f8e", "ceph.block_uuid=70eZh0-oi4f-58Bt-jLne-xG4k-c3oQ-eTbdGt", "ceph.cephx_lockbox_secret=", "ceph.encrypted=0", "ceph.with_tpm=0", "ceph.vdo=0", "ceph.type=block", "ceph.db_uuid=5cUIlD-8uct-5hV5-BdOO-asiY-Bprs-KLFb5G", "ceph.db_device=/dev/ceph-7f98c2d0-9331-4563-b796-a9d01f92a90e/osd-db-9c90a0dd-921a-4e3e-9c02-f8ef3967e406"]
|
||||
creation_time = 1766234030 # 2025-12-20 21:33:50 +0900
|
||||
creation_host = "pve3"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 238467 # 931.512 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 0
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by LVM2 version 2.03.31(2) (2025-02-27): Sat Jul 25 04:15:39 2026
|
||||
|
||||
contents = "Text Format Volume Group"
|
||||
version = 1
|
||||
|
||||
description = "Created *before* executing '/sbin/vgs --separator : --noheadings --units b --unbuffered --nosuffix --options vg_name,vg_size,vg_free,lv_count'"
|
||||
|
||||
creation_host = "pve3" # Linux pve3 7.0.2-6-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.2-6 (2026-05-20T08:55Z) x86_64
|
||||
creation_time = 1784920539 # Sat Jul 25 04:15:39 2026
|
||||
|
||||
pve {
|
||||
id = "oH8tdA-8NxD-JQNp-mtRv-22q8-z8FD-6Oe48B"
|
||||
seqno = 7
|
||||
format = "lvm2" # informational
|
||||
status = ["RESIZEABLE", "READ", "WRITE"]
|
||||
flags = []
|
||||
extent_size = 8192 # 4 Megabytes
|
||||
max_lv = 0
|
||||
max_pv = 0
|
||||
metadata_copies = 0
|
||||
|
||||
physical_volumes {
|
||||
|
||||
pv0 {
|
||||
id = "Jt95bI-RHQ4-WnBF-TCqB-lj6y-OUEG-6g7I3n"
|
||||
device = "/dev/nvme0n1p3" # Hint only
|
||||
|
||||
status = ["ALLOCATABLE"]
|
||||
flags = []
|
||||
dev_size = 123729921 # 58.999 Gigabytes
|
||||
pe_start = 2048
|
||||
pe_count = 15103 # 58.9961 Gigabytes
|
||||
}
|
||||
}
|
||||
|
||||
logical_volumes {
|
||||
|
||||
swap {
|
||||
id = "X0Gqtt-SZD8-hER8-sOTF-NqMz-8ypg-6EPuhW"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 1856 # 7.25 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
root {
|
||||
id = "gmtDqF-k0YD-eB74-gbFn-UP63-LAgK-j2JdHB"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 6383 # 24.9336 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 1856
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data {
|
||||
id = "09Pvtu-BcsK-23mB-ouOp-D5cc-0epZ-RW4UVV"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "thin-pool"
|
||||
metadata = "data_tmeta"
|
||||
pool = "data_tdata"
|
||||
transaction_id = 0
|
||||
chunk_size = 128 # 64 Kilobytes
|
||||
discards = "passdown"
|
||||
zero_new_blocks = 1
|
||||
}
|
||||
}
|
||||
|
||||
data_tmeta {
|
||||
id = "lhM9mI-1ZGn-sMfR-AjCq-j2D6-Plgi-04f8Dr"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12734
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
lvol0_pmspare {
|
||||
id = "apmoWT-rcHL-f3y8-1X4c-cMUy-pvEk-NGm3he"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920372 # 2026-07-25 04:12:52 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12990
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data_tdata {
|
||||
id = "zij0Es-lKe7-KnEZ-0yom-I2R4-sIL1-nTAMbT"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920374 # 2026-07-25 04:12:54 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 8239
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by LVM2 version 2.03.31(2) (2025-02-27): Sat Jul 25 04:15:39 2026
|
||||
|
||||
contents = "Text Format Volume Group"
|
||||
version = 1
|
||||
|
||||
description = "Created *after* executing '/sbin/vgs --separator : --noheadings --units b --unbuffered --nosuffix --options vg_name,vg_size,vg_free,lv_count'"
|
||||
|
||||
creation_host = "pve3" # Linux pve3 7.0.2-6-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.2-6 (2026-05-20T08:55Z) x86_64
|
||||
creation_time = 1784920539 # Sat Jul 25 04:15:39 2026
|
||||
|
||||
pve {
|
||||
id = "oH8tdA-8NxD-JQNp-mtRv-22q8-z8FD-6Oe48B"
|
||||
seqno = 7
|
||||
format = "lvm2" # informational
|
||||
status = ["RESIZEABLE", "READ", "WRITE"]
|
||||
flags = []
|
||||
extent_size = 8192 # 4 Megabytes
|
||||
max_lv = 0
|
||||
max_pv = 0
|
||||
metadata_copies = 0
|
||||
|
||||
physical_volumes {
|
||||
|
||||
pv0 {
|
||||
id = "Jt95bI-RHQ4-WnBF-TCqB-lj6y-OUEG-6g7I3n"
|
||||
device = "/dev/nvme0n1p3" # Hint only
|
||||
|
||||
status = ["ALLOCATABLE"]
|
||||
flags = []
|
||||
dev_size = 123729921 # 58.999 Gigabytes
|
||||
pe_start = 2048
|
||||
pe_count = 15103 # 58.9961 Gigabytes
|
||||
}
|
||||
}
|
||||
|
||||
logical_volumes {
|
||||
|
||||
swap {
|
||||
id = "X0Gqtt-SZD8-hER8-sOTF-NqMz-8ypg-6EPuhW"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 1856 # 7.25 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
root {
|
||||
id = "gmtDqF-k0YD-eB74-gbFn-UP63-LAgK-j2JdHB"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 6383 # 24.9336 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 1856
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data {
|
||||
id = "09Pvtu-BcsK-23mB-ouOp-D5cc-0epZ-RW4UVV"
|
||||
status = ["READ", "WRITE", "VISIBLE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "thin-pool"
|
||||
metadata = "data_tmeta"
|
||||
pool = "data_tdata"
|
||||
transaction_id = 0
|
||||
chunk_size = 128 # 64 Kilobytes
|
||||
discards = "passdown"
|
||||
zero_new_blocks = 1
|
||||
}
|
||||
}
|
||||
|
||||
data_tmeta {
|
||||
id = "lhM9mI-1ZGn-sMfR-AjCq-j2D6-Plgi-04f8Dr"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920369 # 2026-07-25 04:12:49 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12734
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
lvol0_pmspare {
|
||||
id = "apmoWT-rcHL-f3y8-1X4c-cMUy-pvEk-NGm3he"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920372 # 2026-07-25 04:12:52 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 256 # 1024 Megabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 12990
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data_tdata {
|
||||
id = "zij0Es-lKe7-KnEZ-0yom-I2R4-sIL1-nTAMbT"
|
||||
status = ["READ", "WRITE"]
|
||||
flags = []
|
||||
creation_time = 1784920374 # 2026-07-25 04:12:54 +0900
|
||||
creation_host = "proxmox"
|
||||
segment_count = 1
|
||||
|
||||
segment1 {
|
||||
start_extent = 0
|
||||
extent_count = 4495 # 17.5586 Gigabytes
|
||||
|
||||
type = "striped"
|
||||
stripe_count = 1 # linear
|
||||
|
||||
stripes = [
|
||||
"pv0", 8239
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
set interfaces ethernet eth0 address '192.168.10.2/24'
|
||||
set interfaces ethernet eth0 description 'LAN / OSPF to NEC IX'
|
||||
set interfaces ethernet eth0 hw-id 'bc:24:11:d6:4c:ac'
|
||||
set interfaces ethernet eth0 offload gro
|
||||
set interfaces ethernet eth0 offload gso
|
||||
set interfaces ethernet eth0 offload sg
|
||||
set interfaces ethernet eth0 offload tso
|
||||
set interfaces ethernet eth1 address '10.60.0.1/24'
|
||||
set interfaces ethernet eth1 description 'labnet VLAN100 gateway'
|
||||
set interfaces ethernet eth1 hw-id 'bc:24:11:1b:d3:e9'
|
||||
set interfaces ethernet eth1 offload gro
|
||||
set interfaces ethernet eth1 offload gso
|
||||
set interfaces ethernet eth1 offload sg
|
||||
set interfaces ethernet eth1 offload tso
|
||||
set interfaces ethernet eth2 address '10.61.0.1/24'
|
||||
set interfaces ethernet eth2 description 'retronet VLAN110 gateway'
|
||||
set interfaces ethernet eth2 hw-id 'bc:24:11:bd:3c:7a'
|
||||
set interfaces ethernet eth2 offload gro
|
||||
set interfaces ethernet eth2 offload gso
|
||||
set interfaces ethernet eth2 offload sg
|
||||
set interfaces ethernet eth2 offload tso
|
||||
set interfaces loopback lo
|
||||
set protocols ospf area 0 network '192.168.10.0/24'
|
||||
set protocols ospf area 0 network '10.60.0.0/24'
|
||||
set protocols ospf area 0 network '10.61.0.0/24'
|
||||
set protocols ospf interface eth1 passive
|
||||
set protocols ospf interface eth2 passive
|
||||
set protocols ospf parameters router-id '192.168.10.2'
|
||||
set protocols static route 0.0.0.0/0 next-hop 192.168.10.1
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 option default-router '10.60.0.1'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 option domain-name 'ad.ddupan.top'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 option name-server '192.168.10.5'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 range 0 start '10.60.0.100'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 range 0 stop '10.60.0.200'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 static-mapping retrolab ip-address '10.60.0.10'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 static-mapping retrolab mac 'bc:24:11:68:a0:51'
|
||||
set service dhcp-server shared-network-name LABNET subnet 10.60.0.0/24 subnet-id '1'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 option default-router '10.61.0.1'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 option domain-name 'ad.ddupan.top'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 option name-server '192.168.10.5'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 option wins-server '10.61.0.5'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 range 0 start '10.61.0.100'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 range 0 stop '10.61.0.200'
|
||||
set service dhcp-server shared-network-name RETRONET subnet 10.61.0.0/24 subnet-id '2'
|
||||
set service ntp allow-client address '127.0.0.0/8'
|
||||
set service ntp allow-client address '169.254.0.0/16'
|
||||
set service ntp allow-client address '10.0.0.0/8'
|
||||
set service ntp allow-client address '172.16.0.0/12'
|
||||
set service ntp allow-client address '192.168.0.0/16'
|
||||
set service ntp allow-client address '::1/128'
|
||||
set service ntp allow-client address 'fe80::/10'
|
||||
set service ntp allow-client address 'fc00::/7'
|
||||
set service ntp server time1.vyos.net
|
||||
set service ntp server time2.vyos.net
|
||||
set service ntp server time3.vyos.net
|
||||
set service ssh port '22'
|
||||
set system config-management commit-revisions '100'
|
||||
set system console device ttyS0 speed '115200'
|
||||
set system host-name 'vyos-rtr'
|
||||
set system login user vyos authentication encrypted-password '$6$rounds=656000$LrCooxAFgQ99.OkP$a5G8BnDrtoHl/8ZujgVIJr0z9aP.LZL35A1W8SXMnEDxT7eafT9Z3eoUD9jHBseIVuaqK2QpTMNg1YthCdDo..'
|
||||
set system login user vyos authentication plaintext-password ''
|
||||
set system login user vyos authentication public-keys laptop key 'AAAAC3NzaC1lZDI1NTE5AAAAIOLvzIxZhVRd9wEFWR/uCOx7b4HQEdPDiZd8LCN7Hics'
|
||||
set system login user vyos authentication public-keys laptop type 'ssh-ed25519'
|
||||
set system name-server '192.168.10.5'
|
||||
set system option reboot-on-upgrade-failure '5'
|
||||
set system syslog local facility all level 'info'
|
||||
set system syslog local facility local7 level 'debug'
|
||||
Reference in New Issue
Block a user