ha: bridge the Home Assistant VM onto br0 as a first-class LAN device

The VM lived on libvirt's default NAT net (192.168.122.50) behind a
/etc/libvirt/hooks/network hook that inserted FORWARD accepts above
libvirt's REJECT. libvirtd restarts re-inserted its chains on top
without firing the hook, silently cutting clients off from HA.

Now the VM's NIC (virtio, was e1000) bridges into br0: pinned lease
10.42.0.50 / name 'homeassistant' via van-ap dnsmasq, mDNS discovery
works, and no firewall hook can rot. Legacy http://10.42.0.1:8123
kept working via DNAT + same-subnet hairpin masquerade. Hook, libvirt
DHCP reservation, and iptables rules removed; domain XML in ha/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-06 09:38:53 -04:00
co-authored by Claude Fable 5
parent 4117802f69
commit a7819edbc2
4 changed files with 123 additions and 12 deletions
+24
View File
@@ -55,6 +55,7 @@ This directory is the source of truth. The live system files live under `/etc`,
- Cockpit: `https://192.168.10.251:9090` (or `.27`, or ZeroTier). Log in with a Unix account; enable *Administrative access* for action buttons. - Cockpit: `https://192.168.10.251:9090` (or `.27`, or ZeroTier). Log in with a Unix account; enable *Administrative access* for action buttons.
- Regulatory domain **CA** (unlocks 5GHz ch149161 @30dBm, no DFS). - Regulatory domain **CA** (unlocks 5GHz ch149161 @30dBm, no DFS).
- ZeroTier network `d3ecf5726d041b2a`, pushed DNS domain `wrede.pvt` via `192.168.196.115` + `192.168.10.5`. - ZeroTier network `d3ecf5726d041b2a`, pushed DNS domain `wrede.pvt` via `192.168.196.115` + `192.168.10.5`.
- Home Assistant (HAOS in libvirt VM `ha_van`) is bridged into `br0` at **`10.42.0.50:8123`** (pinned DHCP lease, name `homeassistant`); legacy URL `http://10.42.0.1:8123` still works via DNAT.
--- ---
@@ -127,6 +128,14 @@ This directory is the source of truth. The live system files live under `/etc`,
The `hbc` binary itself (venv at `~/venvs/hbd`, symlink `~/bin/hbc`) is installed once via the The `hbc` binary itself (venv at `~/venvs/hbd`, symlink `~/bin/hbc`) is installed once via the
heartbeat project's own installer — see §4. `deploy.sh` only starts the service once it exists. heartbeat project's own installer — see §4. `deploy.sh` only starts the service once it exists.
### `ha/` — Home Assistant VM
| file | → installs to | purpose |
|---|---|---|
| `ha_van.xml` | *(reference only — `virsh define ha/ha_van.xml` to restore)* | libvirt domain: HAOS VM, virtio NIC bridged onto `br0` (MAC `52:54:00:ad:0a:01`). Disk image lives outside the repo. |
Not touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dnsmasq.conf`
(`dhcp-host``10.42.0.50`, `homeassistant`) and the legacy-URL DNAT from `ap/nftables.conf`.
--- ---
## 3. Components & how to operate ## 3. Components & how to operate
@@ -185,6 +194,21 @@ heartbeat project's own installer — see §4. `deploy.sh` only starts the servi
- Read-only status works as any user; Prefer/Up/Down/Restart need *Administrative access* (polkit). - Read-only status works as any user; Prefer/Up/Down/Restart need *Administrative access* (polkit).
- **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device; **Restart** restarts hostapd. - **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device; **Restart** restarts hostapd.
### Home Assistant VM (`ha_van`)
- HAOS runs as a libvirt KVM VM whose NIC is **bridged into `br0`** — it is a first-class LAN
device, not NAT'd behind libvirt's `virbr0`. VanLink + wired clients reach it directly at
`http://10.42.0.50:8123` (or `http://homeassistant:8123` / `homeassistant.local` via mDNS);
ZT clients route in via the ZT-managed `10.42.0.0/24` route. mDNS/SSDP device discovery works
because the VM shares the clients' L2 segment.
- The old NAT-era URL `http://10.42.0.1:8123` keeps working: nftables DNATs it to `10.42.0.50`,
with a hairpin masquerade for same-subnet clients (see comments in `ap/nftables.conf`).
- History: the VM used to sit on libvirt's `default` NAT net (`192.168.122.50`) with a
`/etc/libvirt/hooks/network` hook inserting FORWARD accepts above libvirt's REJECT. That broke
whenever libvirtd re-inserted its chains on restart (hook doesn't fire then) — bridging removed
the whole failure mode. The hook and the libvirt DHCP reservation are gone.
- Operate: `virsh {start,shutdown,domstate} ha_van`; autostart is per libvirt config. Verify:
`curl -s -o /dev/null -w '%{http_code}' http://10.42.0.50:8123/``200`.
### Never sleep (lid-closed operation) ### Never sleep (lid-closed operation)
- wayback lives lid-closed in the van and must stay up. Stock logind `HandleLidSwitch=suspend` would sleep it on lid close (even on AC). The `power/10-vanlink-nolid.conf` drop-in sets all three lid actions to `ignore`; `deploy.sh` also masks every sleep target so idle / GUI / a stray `systemctl suspend` can't suspend it. - wayback lives lid-closed in the van and must stay up. Stock logind `HandleLidSwitch=suspend` would sleep it on lid close (even on AC). The `power/10-vanlink-nolid.conf` drop-in sets all three lid actions to `ignore`; `deploy.sh` also masks every sleep target so idle / GUI / a stray `systemctl suspend` can't suspend it.
- Verify: `busctl get-property org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager HandleLidSwitch``"ignore"`, and `systemctl is-enabled suspend.target``masked`. `busctl call ... CanSuspend` should return `"no"`. - Verify: `busctl get-property org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager HandleLidSwitch``"ignore"`, and `systemctl is-enabled suspend.target``masked`. `busctl call ... CanSuspend` should return `"no"`.
+9 -12
View File
@@ -6,21 +6,18 @@ delete table ip van_router_nat
table ip van_router_nat { table ip van_router_nat {
chain prerouting { chain prerouting {
type nat hook prerouting priority dstnat; policy accept; type nat hook prerouting priority dstnat; policy accept;
# Expose the Home Assistant VM (on libvirt's NAT net, 192.168.122.50) on the # Home Assistant VM (ha_van) is bridged onto br0 at 10.42.0.50 — clients reach
# router's own LAN IP, so VanLink clients reach it at http://10.42.0.1:8123. # it directly. Keep the legacy http://10.42.0.1:8123 URL working for anything
# The matching FORWARD accept lives in iptables (re-applied by the libvirt # that bookmarked it (phones, ZT clients).
# /etc/libvirt/hooks/network hook), since libvirt's reject is in that chain. ip daddr 10.42.0.1 tcp dport 8123 dnat to 10.42.0.50:8123
ip daddr 10.42.0.1 tcp dport 8123 dnat to 192.168.122.50:8123
} }
chain postrouting { chain postrouting {
type nat hook postrouting priority srcnat; policy accept; type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.42.0.0/24 oifname != "br0" masquerade ip saddr 10.42.0.0/24 oifname != "br0" masquerade
# ZeroTier clients reaching the HA VM via the 10.42.0.1:8123 DNAT: masquerade # Hairpin for the legacy 10.42.0.1:8123 DNAT when the client sits on the same
# them onto virbr0 so HA sees the router (192.168.122.1) and the reply path is # subnet as the HA VM: without masquerade the VM would reply directly on br0
# symmetric, exactly like VanLink clients. Matched by ZT ingress interface so it # from 10.42.0.50 and the client (expecting 10.42.0.1) would drop it. ZT-sourced
# covers direct members (192.168.196.x) AND the LAN subnets routed in behind other # traffic doesn't match and doesn't need it — VM replies route back through us.
# ZT nodes (192.168.0/6/8/10/13.x). Scoped to virbr0 so ZT<->ZT and ZT->WAN are ip saddr 10.42.0.0/24 ip daddr 10.42.0.50 tcp dport 8123 oifname "br0" masquerade
# untouched.
iifname "ztuga7c2kh" oifname "virbr0" masquerade
} }
} }
+3
View File
@@ -5,5 +5,8 @@ domain-needed
bogus-priv bogus-priv
dhcp-authoritative dhcp-authoritative
dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h
# Home Assistant VM (libvirt ha_van, bridged onto br0) — pinned address, resolves
# as "homeassistant" via this dnsmasq; HAOS also announces homeassistant.local (mDNS).
dhcp-host=52:54:00:ad:0a:01,10.42.0.50,homeassistant
dhcp-option=option:router,10.42.0.1 dhcp-option=option:router,10.42.0.1
dhcp-option=option:dns-server,10.42.0.1 dhcp-option=option:dns-server,10.42.0.1
+87
View File
@@ -0,0 +1,87 @@
<domain type='kvm'>
<name>ha_van</name>
<uuid>af014c94-de20-4f52-8d6b-438f16cd82e6</uuid>
<description>Home Assistant OS</description>
<memory unit='KiB'>4194304</memory>
<currentMemory unit='KiB'>4194304</currentMemory>
<vcpu placement='static'>2</vcpu>
<os firmware='efi'>
<type arch='x86_64' machine='pc-i440fx-noble-v2'>hvm</type>
<firmware>
<feature enabled='no' name='enrolled-keys'/>
<feature enabled='no' name='secure-boot'/>
</firmware>
<loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE_4M.fd</loader>
<nvram template='/usr/share/OVMF/OVMF_VARS_4M.fd'>/var/lib/libvirt/qemu/nvram/ha_van_VARS.fd</nvram>
<boot dev='hd'/>
</os>
<features>
<acpi/>
<apic/>
</features>
<cpu mode='host-passthrough' check='none' migratable='on'/>
<clock offset='utc'>
<timer name='rtc' tickpolicy='catchup'/>
<timer name='pit' tickpolicy='delay'/>
<timer name='hpet' present='no'/>
</clock>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>destroy</on_crash>
<pm>
<suspend-to-mem enabled='no'/>
<suspend-to-disk enabled='no'/>
</pm>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='/var/lib/libvirt/images/haos_ova-17.3.qcow2'/>
<target dev='sda' bus='scsi'/>
<address type='drive' controller='0' bus='0' target='0' unit='0'/>
</disk>
<controller type='scsi' index='0' model='virtio-scsi'>
<address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/>
</controller>
<controller type='usb' index='0' model='ich9-ehci1'>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x7'/>
</controller>
<controller type='usb' index='0' model='ich9-uhci1'>
<master startport='0'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0' multifunction='on'/>
</controller>
<controller type='usb' index='0' model='ich9-uhci2'>
<master startport='2'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x1'/>
</controller>
<controller type='usb' index='0' model='ich9-uhci3'>
<master startport='4'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x2'/>
</controller>
<controller type='pci' index='0' model='pci-root'/>
<interface type='bridge'>
<mac address='52:54:00:ad:0a:01'/>
<source bridge='br0'/>
<model type='virtio'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/>
</interface>
<serial type='pty'>
<target type='isa-serial' port='0'>
<model name='isa-serial'/>
</target>
</serial>
<console type='pty'>
<target type='serial' port='0'/>
</console>
<input type='mouse' bus='ps2'/>
<input type='keyboard' bus='ps2'/>
<tpm model='tpm-crb'>
<backend type='emulator' version='2.0'/>
</tpm>
<audio id='1' type='none'/>
<memballoon model='virtio'>
<address type='pci' domain='0x0000' bus='0x00' slot='0x05' function='0x0'/>
</memballoon>
</devices>
</domain>