Archived
Compare commits
27
Commits
-22
@@ -7,20 +7,17 @@ keys:
|
|||||||
- &linode-gui age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs
|
- &linode-gui age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs
|
||||||
- &linode-minimal age1e7l8dusgmgfzd2cxrrzwepzjxt69hzqj4epee0cs27u6yg4kxcuqm34ncx
|
- &linode-minimal age1e7l8dusgmgfzd2cxrrzwepzjxt69hzqj4epee0cs27u6yg4kxcuqm34ncx
|
||||||
- &linode-nix-cache age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e
|
- &linode-nix-cache age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e
|
||||||
- &linode-server age1sweerhrga9yf8x6sv0apz4ed4g48rnlcq34rpv20t0rcelwgpgeqwvndzz
|
|
||||||
- &linode-tailscale-router age1f7usptjx9rv4rxauasve200gxtdt9jkqhhdqstlf20wvlm7u75rsjfw50m
|
- &linode-tailscale-router age1f7usptjx9rv4rxauasve200gxtdt9jkqhhdqstlf20wvlm7u75rsjfw50m
|
||||||
- &lxc-docker age17jqc66x9yeshfgd9v78mj483r4zzarqdtuxtrkxe4x5mw679gphshd94th
|
- &lxc-docker age17jqc66x9yeshfgd9v78mj483r4zzarqdtuxtrkxe4x5mw679gphshd94th
|
||||||
- &lxc-minimal age1px0h5l9zp2dww0m8fncrc82kfdmzplsfv2ltat7sna28xpg09pqqcl3s2k
|
- &lxc-minimal age1px0h5l9zp2dww0m8fncrc82kfdmzplsfv2ltat7sna28xpg09pqqcl3s2k
|
||||||
- &lxc-nix-cache age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7
|
- &lxc-nix-cache age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7
|
||||||
- &lxc-pxe-boot age16j42pdc5dr6wnj7xayhkqdj2rny9u68fcqejs50hqq42scssh4gsnrrnlt
|
- &lxc-pxe-boot age16j42pdc5dr6wnj7xayhkqdj2rny9u68fcqejs50hqq42scssh4gsnrrnlt
|
||||||
- &lxc-server age1nruncs4l0ufk7yuc4des8p99c0alfndl0lhsws8tycl5pplfp56s30af5f
|
|
||||||
- &lxc-tailscale-router age1k7d2du5mejsmv5rzavm4xwgpthqvcfsehduquv28nzs53zppa3kqngfxq2
|
- &lxc-tailscale-router age1k7d2du5mejsmv5rzavm4xwgpthqvcfsehduquv28nzs53zppa3kqngfxq2
|
||||||
- &lxc-tor-relay age16kqfmvz4e23hmdlqresnyw69ej604s320mmd49h4hm3fhqchtgyqrws0k2
|
- &lxc-tor-relay age16kqfmvz4e23hmdlqresnyw69ej604s320mmd49h4hm3fhqchtgyqrws0k2
|
||||||
- &proxmox-docker age1arhf2q45zw6wf2uevju4savp575x3m2tfvved5zzq3ay92ynua9s3cm92c
|
- &proxmox-docker age1arhf2q45zw6wf2uevju4savp575x3m2tfvved5zzq3ay92ynua9s3cm92c
|
||||||
- &proxmox-gui age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn
|
- &proxmox-gui age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn
|
||||||
- &proxmox-nix-cache age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68
|
- &proxmox-nix-cache age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68
|
||||||
- &proxmox-pxe-boot age1ug787sgt6st6k82fgkrug2lzltw4qsukrrqqs3w27ewwqj8rg4hsxcmylz
|
- &proxmox-pxe-boot age1ug787sgt6st6k82fgkrug2lzltw4qsukrrqqs3w27ewwqj8rg4hsxcmylz
|
||||||
- &proxmox-server age1529taqdwr6t0w7cvzmty0d5y5593wffl0krt48j6uc4u39k56g2qf6ywtp
|
|
||||||
- &proxmox-tailscale-router age1zhfyuzlq40reuqlr34gf77852nhs3t6mqfzrqmas8z6sxk7tcfhsungrm0
|
- &proxmox-tailscale-router age1zhfyuzlq40reuqlr34gf77852nhs3t6mqfzrqmas8z6sxk7tcfhsungrm0
|
||||||
- &proxmox-ha-server-1 age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6
|
- &proxmox-ha-server-1 age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6
|
||||||
- &proxmox-ha-server-2 age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r
|
- &proxmox-ha-server-2 age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r
|
||||||
@@ -41,20 +38,17 @@ creation_rules:
|
|||||||
- *linode-gui
|
- *linode-gui
|
||||||
- *linode-minimal
|
- *linode-minimal
|
||||||
- *linode-nix-cache
|
- *linode-nix-cache
|
||||||
- *linode-server
|
|
||||||
- *linode-tailscale-router
|
- *linode-tailscale-router
|
||||||
- *lxc-docker
|
- *lxc-docker
|
||||||
- *lxc-minimal
|
- *lxc-minimal
|
||||||
- *lxc-nix-cache
|
- *lxc-nix-cache
|
||||||
- *lxc-pxe-boot
|
- *lxc-pxe-boot
|
||||||
- *lxc-server
|
|
||||||
- *lxc-tailscale-router
|
- *lxc-tailscale-router
|
||||||
- *lxc-tor-relay
|
- *lxc-tor-relay
|
||||||
- *proxmox-docker
|
- *proxmox-docker
|
||||||
- *proxmox-gui
|
- *proxmox-gui
|
||||||
- *proxmox-nix-cache
|
- *proxmox-nix-cache
|
||||||
- *proxmox-pxe-boot
|
- *proxmox-pxe-boot
|
||||||
- *proxmox-server
|
|
||||||
- *proxmox-tailscale-router
|
- *proxmox-tailscale-router
|
||||||
- *proxmox-ha-server-1
|
- *proxmox-ha-server-1
|
||||||
- *proxmox-ha-server-2
|
- *proxmox-ha-server-2
|
||||||
@@ -67,14 +61,6 @@ creation_rules:
|
|||||||
- *lxc-nix-cache
|
- *lxc-nix-cache
|
||||||
- *proxmox-nix-cache
|
- *proxmox-nix-cache
|
||||||
|
|
||||||
- path_regex: secrets/server\.yaml$
|
|
||||||
key_groups:
|
|
||||||
- age:
|
|
||||||
- *admin
|
|
||||||
- *linode-server
|
|
||||||
- *lxc-server
|
|
||||||
- *proxmox-server
|
|
||||||
|
|
||||||
- path_regex: secrets/tor-relay\.yaml$
|
- path_regex: secrets/tor-relay\.yaml$
|
||||||
key_groups:
|
key_groups:
|
||||||
- age:
|
- age:
|
||||||
@@ -174,14 +160,6 @@ creation_rules:
|
|||||||
- *proxmox-gui
|
- *proxmox-gui
|
||||||
- *linode-gui
|
- *linode-gui
|
||||||
|
|
||||||
- path_regex: secrets/server\.keytab$
|
|
||||||
key_groups:
|
|
||||||
- age:
|
|
||||||
- *admin
|
|
||||||
- *linode-server
|
|
||||||
- *lxc-server
|
|
||||||
- *proxmox-server
|
|
||||||
|
|
||||||
- path_regex: secrets/docker\.keytab$
|
- path_regex: secrets/docker\.keytab$
|
||||||
key_groups:
|
key_groups:
|
||||||
- age:
|
- age:
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# Storage/Cluster Network Segmentation Audit — pve1.sweet.home
|
||||||
|
|
||||||
|
**Date:** 2026-07-29
|
||||||
|
**Scope:** Read-only discovery of pve1.sweet.home host networking, HA cluster VMs (200/201), and Docker CT (105). No changes made.
|
||||||
|
|
||||||
|
> **Implementation status — 2026-07-29:** All recommendations from this audit have been
|
||||||
|
> implemented in the same session. See `docs/ip-addressing.md` for the current state.
|
||||||
|
> Key decisions that diverged from the original recommendations:
|
||||||
|
> - VLAN IDs renumbered: cluster → VLAN 10 (192.168.10.x), storage-client → VLAN 20 (192.168.20.x)
|
||||||
|
> - Two Pacemaker VIPs: `vip-lan` (192.168.2.229, NFS for LAN) and `vip-storage` (192.168.20.229, NFS + iSCSI for VLAN 20)
|
||||||
|
> - NFS served on **both** VIPs (each firewalled to its own subnet); iSCSI available on VLAN 20 but NFS is preferred for docker to support future Docker Swarm multi-host access
|
||||||
|
> - `corosync.conf` ring1 added using LAN IPs (RF-1 resolved)
|
||||||
|
> - `vmbr2` created and NICs added to HA VMs and docker CT (RF-6/RF-7 resolved)
|
||||||
|
> - iSCSI portal remains on `[::0]`; firewall enforces VLAN 20 restriction (RF-4 mitigated)
|
||||||
|
> - STONITH still disabled (RF-3 deferred — accepted risk during development phase)
|
||||||
|
> - iSCSI ACLs not configured (RF-5 deferred — iSCSI not in active use)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current State Summary
|
||||||
|
|
||||||
|
### pve1.sweet.home Host — Physical NICs
|
||||||
|
|
||||||
|
| Interface | Speed/Duplex | Notes |
|
||||||
|
|-----------|-------------|-------|
|
||||||
|
| `nic0` | 2500 Mb/s / Full (2.5GbE) | Only active physical NIC; sole bridge port for vmbr0 |
|
||||||
|
| `nic1` | (not connected / no data) | Present in config, not UP |
|
||||||
|
| `wlp4s0` | DOWN | WiFi, unused |
|
||||||
|
|
||||||
|
No bonding configured. Every guest's traffic ultimately funnels through the single 2.5GbE `nic0`.
|
||||||
|
|
||||||
|
### Proxmox Bridges
|
||||||
|
|
||||||
|
| Bridge | Physical NIC | Host IP | Subnet | VLAN-aware | Purpose (current) |
|
||||||
|
|--------|-------------|---------|--------|------------|-------------------|
|
||||||
|
| `vmbr0` | `nic0` (2.5GbE) | 192.168.2.245/24 | 192.168.2.0/24 | No | General LAN, management, **iSCSI/NFS VIP** |
|
||||||
|
| `vmbr1` | **none** (internal-only) | — | 192.168.4.224/29 | No | Corosync heartbeat + DRBD replication |
|
||||||
|
|
||||||
|
`vmbr1` has `bridge-ports none` in `/etc/network/interfaces.d/vmbr1.conf` — it is a purely software bridge with zero physical uplink. All traffic on it stays inside the hypervisor's memory.
|
||||||
|
|
||||||
|
### Guest NIC Assignments
|
||||||
|
|
||||||
|
| Guest | VMID | Role | NIC | Bridge | IP | Traffic type |
|
||||||
|
|-------|------|------|-----|--------|----|--------------|
|
||||||
|
| nix-cache | CT 102 | Build cache | eth0 | vmbr0 | DHCP/LAN | LAN |
|
||||||
|
| pxe-boot | CT 103 | PXE/TFTP | eth0 | vmbr0 | DHCP/LAN | LAN |
|
||||||
|
| tor-relay | CT 104 | Tor | eth0 | vmbr0 | DHCP/LAN | LAN |
|
||||||
|
| **docker** | **CT 105** | **Docker host** | **eth0** | **vmbr0** | **192.168.2.225/24** | **LAN only** |
|
||||||
|
| pdm | CT 106 | Proxmox mgmt | eth0 | vmbr0 | 192.168.2.220/24 | LAN |
|
||||||
|
| server | VM 101 | General server | net0 | vmbr0 | DHCP/LAN | LAN |
|
||||||
|
| tailscale-router | VM 107 | Tailscale exit | net0 | vmbr0 | DHCP/LAN | LAN |
|
||||||
|
| domain-controller | VM 108 | FreeIPA | net0 | vmbr0 | 192.168.2.253/24 | LAN |
|
||||||
|
| **ha-server-1** | **VM 200** | **HA primary** | **net0** | **vmbr0** | **192.168.2.228/24 + VIP 192.168.2.229/24** | **LAN + VIP** |
|
||||||
|
| **ha-server-1** | **VM 200** | **HA primary** | **net1** | **vmbr1** | **192.168.4.228/29** | **Corosync + DRBD** |
|
||||||
|
| **ha-server-2** | **VM 201** | **HA secondary** | **net0** | **vmbr0** | **192.168.2.227/24** | **LAN** |
|
||||||
|
| **ha-server-2** | **VM 201** | **HA secondary** | **net1** | **vmbr1** | **192.168.4.227/29** | **Corosync + DRBD** |
|
||||||
|
|
||||||
|
### Corosync/Pacemaker State
|
||||||
|
|
||||||
|
- **Transport:** knet/UDP
|
||||||
|
- **Rings:** 1 only — ring0 on `192.168.4.228` / `192.168.4.227` (vmbr1/ens19)
|
||||||
|
- **Cluster status:** Both nodes online, DC = ha-server-1, quorum achieved
|
||||||
|
- **STONITH:** `stonith-enabled: false`
|
||||||
|
- **no-quorum-policy:** `ignore`
|
||||||
|
- **Resources (all active on ha-server-1):**
|
||||||
|
- `ms-drbd0` — promotable DRBD clone (Primary: ha-server-1, Secondary: ha-server-2)
|
||||||
|
- `xfs-data` — XFS on `/dev/drbd0` → `/srv/ha-data`
|
||||||
|
- `iscsi-target` — targetctl service
|
||||||
|
- `nfs-server` — nfs-server service
|
||||||
|
- `vip` — IPaddr2 at **192.168.2.229/24** (no `nic=` parameter specified; floats to ens18/vmbr0 automatically based on subnet match)
|
||||||
|
- **Resource ordering:** ha-group starts only after DRBD is promoted; collocated with Promoted DRBD clone.
|
||||||
|
|
||||||
|
### DRBD State
|
||||||
|
|
||||||
|
- **Resource:** `ha-data` (DRBD 8.4.11 kernel module, config in `/etc/drbd.conf`)
|
||||||
|
- **Protocol:** C (synchronous)
|
||||||
|
- **Replication endpoints:**
|
||||||
|
- ha-server-1: `192.168.4.228:7789` (ens19 / vmbr1)
|
||||||
|
- ha-server-2: `192.168.4.227:7789` (ens19 / vmbr1)
|
||||||
|
- **State at audit time:** Initial sync in progress — ~20% complete, ~40 MB/s, ~34 min remaining (100 GB disk)
|
||||||
|
- **Fencing config:** `fencing resource-only` + fence-peer/unfence-peer handlers
|
||||||
|
|
||||||
|
### iSCSI Target
|
||||||
|
|
||||||
|
- **IQN:** `iqn.2026-01.home.sweet:ha-storage`
|
||||||
|
- **Portal:** `[::0]:3260` — confirmed listening on all interfaces (`ss -tnlp` shows `*:3260 *:*`)
|
||||||
|
- **LUN 0:** fileio backstore — `/srv/ha-data/iscsi-lun.img` (10 GiB, write-thru)
|
||||||
|
- **ACLs:** **None** (`no-gen-acls`, `no-auth`)
|
||||||
|
- **VIP (intended portal):** 192.168.2.229 — on vmbr0/LAN, no storage-NIC-specific binding
|
||||||
|
|
||||||
|
### NFS Exports
|
||||||
|
|
||||||
|
Served from the same `ha-group` as iSCSI (starts/stops together):
|
||||||
|
|
||||||
|
| Export path | Client subnet |
|
||||||
|
|------------|---------------|
|
||||||
|
| `/srv/ha-data/docker/{config,databases,volumes,nextcloud-data}` | 192.168.2.0/24 |
|
||||||
|
| `/srv/ha-data/proxmox/{iso,lxc}` | 192.168.2.0/24 |
|
||||||
|
| `/srv/ha-data/pxe-boot/images` | 192.168.2.0/24 |
|
||||||
|
| `/srv/ha-data/raspi/volumes` | 192.168.2.0/24 |
|
||||||
|
|
||||||
|
All NFS exports restrict to 192.168.2.0/24 and are served via the VIP at 192.168.2.229. `rw,sync,no_subtree_check,no_root_squash`.
|
||||||
|
|
||||||
|
### Docker Host Current State
|
||||||
|
|
||||||
|
- **Single NIC:** eth0 on vmbr0, 192.168.2.225/24, gateway 192.168.2.254
|
||||||
|
- **Route to storage network (192.168.4.x):** none — no NIC and no route
|
||||||
|
- **iSCSI sessions:** none
|
||||||
|
- **iSCSI nodes discovered:** none
|
||||||
|
- **Docker networks:** several active compose-project networks (core_traefik, core_nextcloud, core_passbolt, core_gramps, core_docker-socket-proxy, plus CI isolation networks)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Risk Flags
|
||||||
|
|
||||||
|
### RF-1: Corosync has only one ring (no heartbeat path redundancy)
|
||||||
|
|
||||||
|
`corosync.conf` defines only `ring0_addr` for each node, using 192.168.4.x on vmbr1. No `ring1_addr` / second knet link is configured. On a single Proxmox host, vmbr1 is a software bridge (no physical NIC), so physical link failure is not the concern — but a kernel network stack hiccup, a `pveproxy` restart dropping bridge state, or vmbr1 getting disrupted during heavy DRBD sync all leave corosync with zero fallback path. Missed heartbeats on a two-node cluster with `no-quorum-policy: ignore` do not cause a clean shutdown; they cause a false failover or split-brain.
|
||||||
|
|
||||||
|
Adding the LAN addresses (192.168.2.228 / 192.168.2.227 via ens18/vmbr0) as a second knet link would provide a backup path with no infrastructure changes needed.
|
||||||
|
|
||||||
|
### RF-2: Corosync heartbeat and DRBD replication share vmbr1 — no isolation between them
|
||||||
|
|
||||||
|
Both corosync (knet/UDP, ~1 kB heartbeat packets every ~100 ms) and DRBD replication (protocol C, synchronous, syncing at ~40 MB/s on a 100 GB initial fill at audit time) traverse the same `vmbr1` virtual bridge and terminate on the same ens19 NIC pair inside each HA VM. Under heavy DRBD write load, the guest-kernel scheduler's NIC transmit queue processes both flows together. While corosync's heartbeat is tiny, the absence of QoS/priority marking on vmbr1 means a DRBD burst can delay a heartbeat enough to trigger a ring fault warning. This is a latent risk that grows under high-write workloads.
|
||||||
|
|
||||||
|
### RF-3: STONITH disabled — split-brain protection relies solely on DRBD's resource-only fencing
|
||||||
|
|
||||||
|
`stonith-enabled: false` in the CIB. With `no-quorum-policy: ignore`, both nodes will continue running if corosync loses communication. DRBD's `fencing resource-only` does call `fence-peer` before allowing a Primary promotion, which provides some protection, but there is no hard external power fence to guarantee the other node actually stops. In a real split-brain (both nodes believe they are Primary), data corruption on the shared XFS filesystem is possible. **This is the highest-severity risk in the current setup.**
|
||||||
|
|
||||||
|
Getting STONITH to work on Proxmox-hosted VMs requires either a `fence_pve` agent (Proxmox API fencing) or `fence_virtd` (QEMU guest agent fencing). Neither is configured.
|
||||||
|
|
||||||
|
### RF-4: iSCSI portal bound to `[::0]:3260` — listens on every interface, not just the VIP
|
||||||
|
|
||||||
|
The targetcli portal is `[::0]:3260` (confirmed: `*:3260 *:*` in ss). This means the target is reachable on:
|
||||||
|
- 192.168.2.229 (VIP — correct, failover-safe)
|
||||||
|
- 192.168.2.228 (ha-server-1 LAN IP — does **not** move during failover; an initiator session connecting here would break on failover)
|
||||||
|
- 192.168.4.228 (storage NIC — not reachable by the Docker host today, but unintentionally exposed)
|
||||||
|
|
||||||
|
Binding the portal explicitly to the VIP IP instead of wildcard eliminates the non-VIP reachability risks.
|
||||||
|
|
||||||
|
### RF-5: iSCSI has zero ACLs and no authentication
|
||||||
|
|
||||||
|
`targetcli ls` shows `acls: 0`, `no-gen-acls`, `no-auth`. Any host that can reach port 3260 on any of the above IPs can log into the LUN with no credentials. The Docker host is not yet configured as an initiator — but neither is it blocked.
|
||||||
|
|
||||||
|
### RF-6: Docker host has no path to the storage network — iSCSI would traverse vmbr0/nic0
|
||||||
|
|
||||||
|
CT 105 (docker, 192.168.2.225) has one NIC, on vmbr0. To reach the VIP at 192.168.2.229, iSCSI traffic would travel:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker (eth0/vmbr0) → nic0 (2.5GbE) → vmbr0 → tap200i0 (VM 200 net0/ens18)
|
||||||
|
```
|
||||||
|
|
||||||
|
All of the following share this same path over vmbr0 → nic0:
|
||||||
|
- Docker container traffic (outbound and inter-container)
|
||||||
|
- CI/CD runner traffic (Gitea Actions jobs visible in `docker network ls`)
|
||||||
|
- NFS mounts from pxe-boot, proxmox host itself, and other LAN clients
|
||||||
|
- iSCSI block traffic (protocol-sensitive to latency and retransmit)
|
||||||
|
|
||||||
|
A Nextcloud upload or a CI `nix build` job can saturate nic0 and starve the iSCSI session, causing command timeouts and filesystem errors on the Docker host.
|
||||||
|
|
||||||
|
### RF-7: VIP is on the LAN interface with no storage-specific binding
|
||||||
|
|
||||||
|
The Pacemaker `vip` resource specifies `ip=192.168.2.229, cidr_netmask=24` with no `nic=` parameter. Pacemaker's IPaddr2 agent selects the interface by longest-prefix match, landing it on ens18 (vmbr0/LAN). There is no way to keep this VIP from competing with general LAN traffic on nic0 without moving the VIP to a separate subnet on a different virtual bridge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recommended Target Layout
|
||||||
|
|
||||||
|
### Design constraints
|
||||||
|
|
||||||
|
- Single Proxmox host: all traffic ultimately shares nic0's bandwidth. The goal is QoS partitioning via separate bridges and subnets, not true physical isolation.
|
||||||
|
- Future physical split: bridge/VLAN IDs chosen here should map cleanly to physical uplink VLAN tags when the HA nodes move to separate hardware.
|
||||||
|
|
||||||
|
### Proposed bridge layout
|
||||||
|
|
||||||
|
| Bridge | Physical port | VLAN tag (future) | Subnet | Purpose |
|
||||||
|
|--------|-------------|-------------------|--------|---------|
|
||||||
|
| `vmbr0` | nic0 | untagged / VLAN 1 | 192.168.2.0/24 | **LAN/management only** — no storage traffic |
|
||||||
|
| `vmbr1` | (none / VLAN 10 on future trunk) | VLAN 10 | 192.168.4.224/29 | **Corosync heartbeat + DRBD replication** (current, keep) |
|
||||||
|
| `vmbr2` *(new)* | (none / VLAN 20 on future trunk) | VLAN 20 | 192.168.5.0/24 | **Storage: iSCSI + NFS client access** |
|
||||||
|
|
||||||
|
This is the minimum-disruption path: vmbr1 stays as-is (no DRBD reconfiguration needed), and the new vmbr2 gives the Docker host a direct path to the storage VIP without crossing vmbr0.
|
||||||
|
|
||||||
|
If stricter isolation is later desired, DRBD can be migrated from vmbr1 to vmbr2 in a separate maintenance window (see §5), leaving vmbr1 as corosync-only.
|
||||||
|
|
||||||
|
### Per-guest NIC assignments in target layout
|
||||||
|
|
||||||
|
| Guest | VMID | NIC | Bridge | Proposed IP | Purpose |
|
||||||
|
|-------|------|-----|--------|-------------|---------|
|
||||||
|
| ha-server-1 | VM 200 | net0 | vmbr0 | 192.168.2.228/24 | LAN/management (keep) |
|
||||||
|
| ha-server-1 | VM 200 | net1 | vmbr1 | 192.168.4.228/29 | Corosync + DRBD (keep) |
|
||||||
|
| ha-server-1 | VM 200 | **net2 (new)** | **vmbr2** | **192.168.5.1/24** | iSCSI + NFS storage client |
|
||||||
|
| ha-server-2 | VM 201 | net0 | vmbr0 | 192.168.2.227/24 | LAN/management (keep) |
|
||||||
|
| ha-server-2 | VM 201 | net1 | vmbr1 | 192.168.4.227/29 | Corosync + DRBD (keep) |
|
||||||
|
| ha-server-2 | VM 201 | **net2 (new)** | **vmbr2** | **192.168.5.2/24** | iSCSI + NFS storage client |
|
||||||
|
| docker | CT 105 | net0 | vmbr0 | 192.168.2.225/24 | LAN/management (keep) |
|
||||||
|
| docker | CT 105 | **net1 (new)** | **vmbr2** | **192.168.5.10/24** | iSCSI + NFS |
|
||||||
|
|
||||||
|
**VIP target:** `192.168.5.100/24` on vmbr2. The Pacemaker `vip` resource changes from `ip=192.168.2.229` to `ip=192.168.5.100, nic=<ens20>` (whichever name the new NIC gets inside the HA VMs). The existing `192.168.2.229` LAN VIP can optionally be retained as a separate static alias on ens18 for management-plane access, but should not be the iSCSI portal target.
|
||||||
|
|
||||||
|
**iSCSI portal:** Bind to `192.168.5.100:3260` instead of `[::0]:3260`. In targetcli: remove the wildcard portal, add `portals/ create 192.168.5.100`.
|
||||||
|
|
||||||
|
**NFS exports:** NFS is a file-level protocol and is fine being accessed over a routed path. After the VIP moves, non-docker LAN clients (proxmox host, pxe-boot, raspi) can reach NFS either via a static route to 192.168.5.0/24 or by keeping a secondary static alias at 192.168.2.229 on ens18 dedicated to NFS. Either approach works — NFS handles reconnect gracefully in ways iSCSI block I/O cannot.
|
||||||
|
|
||||||
|
**Corosync second ring (independent, low-disruption improvement):**
|
||||||
|
|
||||||
|
Add a second knet link using the LAN addresses as a backup heartbeat path. Edit `corosync.conf` on both nodes:
|
||||||
|
|
||||||
|
```
|
||||||
|
node { ring0_addr: 192.168.4.228; ring1_addr: 192.168.2.228; name: ha-server-1; nodeid: 1; }
|
||||||
|
node { ring0_addr: 192.168.4.227; ring1_addr: 192.168.2.227; name: ha-server-2; nodeid: 2; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a corosync service restart (brief cluster pause, ~5 seconds), no interface or bridge changes.
|
||||||
|
|
||||||
|
**Future physical-host split:**
|
||||||
|
When ha-server-1 and ha-server-2 move to separate physical machines, vmbr1 and vmbr2 become VLAN-tagged sub-interfaces on a physical trunk (e.g. VLAN 10 → cluster, VLAN 20 → storage). The bridge/subnet/IP layout above is designed so the tag numbers can be layered onto the existing addresses without renumbering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gap List
|
||||||
|
|
||||||
|
| Gap | Action needed |
|
||||||
|
|-----|--------------|
|
||||||
|
| `vmbr2` does not exist on pve1 | Create internal bridge: `/etc/network/interfaces.d/vmbr2.conf` with `bridge-ports none`, `inet manual` |
|
||||||
|
| VM 200 and VM 201 have no net2 | `qm set 200 --net2 virtio,bridge=vmbr2` / `qm set 201 --net2 virtio,bridge=vmbr2` (hot-plug, no reboot needed) |
|
||||||
|
| CT 105 has no net1 | `pct set 105 --net1 name=eth1,bridge=vmbr2,ip=192.168.5.10/24` |
|
||||||
|
| HA VMs have no OS config for the new NIC | NixOS `networking.interfaces.<ens20>` with `ipv4.addresses = [{address="192.168.5.1"; prefixLength=24;}]` per host (name may differ — check `ip link` after hotplug) |
|
||||||
|
| VIP needs to move to 192.168.5.100 on vmbr2 | `pcs resource update vip ip=192.168.5.100 cidr_netmask=24 nic=<ens20>` |
|
||||||
|
| iSCSI portal bound to `[::0]` | `targetcli /iscsi/iqn.2026-01.home.sweet:ha-storage/tpg1/portals delete ::0 3260` then `create 192.168.5.100`; save and restart via `pcs resource restart iscsi-target` |
|
||||||
|
| iSCSI ACLs empty | Get Docker initiator IQN via `iscsiadm -m iface` on CT 105, then add via targetcli `acls/ create <iqn>` |
|
||||||
|
| Docker host has no iSCSI initiator config | `iscsiadm -m discoverydb -t sendtargets -p 192.168.5.100 -D` then `iscsiadm -m node -l` once ACLs are set |
|
||||||
|
| Corosync single ring | Add `ring1_addr` entries in `corosync.conf` using LAN IPs; restart corosync cluster-wide (one node at a time) |
|
||||||
|
| STONITH not configured | Evaluate `fence_pve` (Proxmox API agent); document accepted risk if deferred |
|
||||||
|
| DRBD still on vmbr1 (optional, separate window) | Stop ms-drbd0 via pcs, edit `/etc/drbd.conf` on both nodes (change `192.168.4.x` → `192.168.5.x`), restart DRBD, re-enable via pcs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migration Notes
|
||||||
|
|
||||||
|
### Non-disruptive (no service impact)
|
||||||
|
|
||||||
|
- **Create vmbr2 on pve1:** Bridge definition edit only; no effect on existing bridges or guests.
|
||||||
|
- **Hot-add net2 to VMs 200/201:** Proxmox allows adding a NIC without reboot (`qm set 200 --net2 ...`). The NIC appears inside the VM immediately via QEMU hotplug but will be unconfigured (down) inside NixOS until the NixOS config is deployed — no impact on running services.
|
||||||
|
- **Add net1 to CT 105:** LXC NIC hotplug works similarly; CT does not need to restart.
|
||||||
|
- **Add corosync ring1:** Requires `systemctl restart corosync` on both nodes (one at a time). Pacemaker briefly sees corosync go offline and recover; with two nodes and `wait_for_all: 0`, this typically completes in under 5 seconds and resources stay running.
|
||||||
|
|
||||||
|
### Disruptive — requires maintenance window
|
||||||
|
|
||||||
|
- **Move VIP from 192.168.2.229 to 192.168.5.100:** `pcs resource update vip ip=192.168.5.100` causes Pacemaker to immediately stop the old VIP and start the new one. Any NFS mounts referencing 192.168.2.229 will stall until remounted at the new address (or a static alias is added at 192.168.2.229 on ens18). No iSCSI sessions exist yet, so no iSCSI disruption.
|
||||||
|
|
||||||
|
- **Change iSCSI portal from `[::0]` to VIP-specific:** Requires `pcs resource restart iscsi-target` after the targetcli portal change — brief target unavailability. Any initiator sessions (once configured) will need to re-login.
|
||||||
|
|
||||||
|
- **Migrate DRBD replication from 192.168.4.x to 192.168.5.x** (optional — only needed to give corosync sole ownership of vmbr1):
|
||||||
|
1. `pcs resource disable ms-drbd0` — demotes DRBD Primary, stops ha-group (unmounts XFS, stops iSCSI + NFS + VIP)
|
||||||
|
2. `drbdadm down ha-data` on both nodes
|
||||||
|
3. Edit `/etc/drbd.conf` on both nodes (change `address` lines)
|
||||||
|
4. `drbdadm up ha-data` on both nodes
|
||||||
|
5. `pcs resource enable ms-drbd0` — Pacemaker re-promotes, mounts, starts services
|
||||||
|
|
||||||
|
DRBD does **not** require a full resync when only the address changes — the disk data and metadata are unchanged; only the TCP connection endpoint changes. However, the initial sync was in progress at audit time (~20% at ~40 MB/s). Recommend waiting for that sync to complete before scheduling this migration.
|
||||||
+115
-18
@@ -2,16 +2,88 @@
|
|||||||
|
|
||||||
## Subnets
|
## Subnets
|
||||||
|
|
||||||
| Subnet | CIDR | Purpose | Routed? |
|
| Subnet | VLAN | CIDR | Purpose | Routed? |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| LAN | `192.168.2.0/24` | General LAN — clients and infrastructure | Yes (gateway .254) |
|
| LAN | 2 (native/untagged) | `192.168.2.0/24` | General LAN — clients and infrastructure | Yes (gateway .254) |
|
||||||
| Storage | `192.168.4.0/29` | HA file server DRBD replication | No — internal `vmbr1` only, no uplink |
|
| Cluster | 10 | `192.168.10.224/29` | HA file server DRBD replication + Corosync heartbeat | No — internal `vmbr1` only, no uplink |
|
||||||
|
| Storage client | 20 | `192.168.20.0/24` | HA file server NFS (and iSCSI if needed) — docker and swarm nodes mount from VIP here | No — internal `vmbr2` only, no uplink |
|
||||||
|
|
||||||
The storage subnet never leaves pve1. `vmbr1` is a Proxmox Linux bridge with no physical port
|
The cluster and storage-client subnets never leave pve1. `vmbr1` and `vmbr2` are Proxmox Linux
|
||||||
attached; traffic between the two HA file server VMs stays in-kernel.
|
bridges with no physical port attached; traffic between guests on each bridge stays in-kernel.
|
||||||
|
|
||||||
The host octet is consistent across subnets for any host that has multiple interfaces — e.g.
|
VLAN IDs match the third octet of each subnet (VLAN 2 → 192.168.**2**.x, VLAN 10 → 192.168.**10**.x,
|
||||||
ha-node1 is always `.228` (LAN: `192.168.2.228`, storage: `192.168.4.228`).
|
VLAN 20 → 192.168.**20**.x). The host octet is consistent across all subnets — e.g. ha-node1
|
||||||
|
is always `.228`: `192.168.2.228` (LAN), `192.168.10.228` (cluster), `192.168.20.228` (storage client).
|
||||||
|
|
||||||
|
**Protocol separation** (enforced by firewall on HA nodes):
|
||||||
|
- NFS (ports 111, 2049, 20048): both subnets, each restricted to its own CIDR
|
||||||
|
- VLAN 2 only → `vip-lan` (192.168.2.229) — pxe-boot and other LAN clients
|
||||||
|
- VLAN 20 only → `vip-storage` (192.168.20.229) — docker, future swarm nodes
|
||||||
|
- iSCSI (port 3260): VLAN 20 only — available but not in active use; NFS is preferred
|
||||||
|
for multi-host access (shared volumes across a Docker Swarm require a shared filesystem,
|
||||||
|
not per-host block devices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS Zones
|
||||||
|
|
||||||
|
FreeIPA (domain-controller.sweet.home) is authoritative for all zones. Three
|
||||||
|
zones correspond to the three subnets — one per VLAN. All zones are internal
|
||||||
|
only; no external delegation.
|
||||||
|
|
||||||
|
### sweet.home — VLAN 2 (192.168.2.x)
|
||||||
|
|
||||||
|
General LAN zone. All infrastructure hostnames live here.
|
||||||
|
|
||||||
|
| Hostname | A record | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `domain-controller.sweet.home` | `192.168.2.253` | FreeIPA / KDC / DNS |
|
||||||
|
| `ha-vip-lan.sweet.home` | `192.168.2.229` | Pacemaker `vip-lan` — NFS for LAN clients |
|
||||||
|
| `ha-server-1.sweet.home` | `192.168.2.228` | HA node 1 management NIC |
|
||||||
|
| `ha-server-2.sweet.home` | `192.168.2.227` | HA node 2 management NIC |
|
||||||
|
| `server.sweet.home` | `192.168.2.226` | Current ZFS/NFS server (retiring) |
|
||||||
|
| `docker.sweet.home` | `192.168.2.225` | Docker/Traefik host |
|
||||||
|
| `nix-cache.sweet.home` | `192.168.2.224` | Nix binary cache + remote builder |
|
||||||
|
| `pxe-boot.sweet.home` | `192.168.2.223` | PXE / TFTP / HTTP netboot |
|
||||||
|
| `tailscale-router.sweet.home` | `192.168.2.222` | Tailscale exit node |
|
||||||
|
| `tor-relay.sweet.home` | `192.168.2.221` | Tor relay |
|
||||||
|
| `pdm.sweet.home` | `192.168.2.220` | Proxmox Deploy Manager |
|
||||||
|
| `nixos.sweet.home` | `192.168.2.39` | Bare-metal workstation (DHCP) |
|
||||||
|
| `pve1.sweet.home` | `192.168.2.245` | Proxmox VE hypervisor |
|
||||||
|
| `pbs.sweet.home` | `192.168.2.244` | Proxmox Backup Server |
|
||||||
|
|
||||||
|
PTR records exist for all static hosts. The workstation (`nixos.sweet.home`) is
|
||||||
|
DHCP-assigned; its PTR is omitted.
|
||||||
|
|
||||||
|
### cluster.home — VLAN 10 (192.168.10.x)
|
||||||
|
|
||||||
|
Internal only — Corosync ring0 heartbeat and DRBD replication between HA nodes.
|
||||||
|
No VIP exists on this subnet (DRBD/Corosync endpoints are static per-node IPs).
|
||||||
|
|
||||||
|
| Hostname | A record | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `ha-server-1.cluster.home` | `192.168.10.228` | HA node 1 cluster NIC (ens19 / vmbr1) |
|
||||||
|
| `ha-server-2.cluster.home` | `192.168.10.227` | HA node 2 cluster NIC (ens19 / vmbr1) |
|
||||||
|
|
||||||
|
PTR records exist for both. DNS here is for debugging convenience — DRBD and
|
||||||
|
Corosync use the IPs from the NixOS config directly, not DNS.
|
||||||
|
|
||||||
|
### storage.home — VLAN 20 (192.168.20.x)
|
||||||
|
|
||||||
|
Internal only — NFS (and iSCSI) client access to the HA storage VIP. NFS clients
|
||||||
|
mount from **`nfs.storage.home`** (the Pacemaker floating VIP) so mounts survive
|
||||||
|
failover transparently without reconfiguration.
|
||||||
|
|
||||||
|
| Hostname | A record | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `nfs.storage.home` | `192.168.20.229` | Pacemaker `vip-storage` — NFS + iSCSI VIP |
|
||||||
|
| `ha-server-1.storage.home` | `192.168.20.228` | HA node 1 storage-client NIC (ens20 / vmbr2) |
|
||||||
|
| `ha-server-2.storage.home` | `192.168.20.227` | HA node 2 storage-client NIC (ens20 / vmbr2) |
|
||||||
|
| `docker.storage.home` | `192.168.20.225` | Docker host storage-client NIC (eth1 / vmbr2) |
|
||||||
|
| `server.storage.home` | `192.168.20.226` | server VM storage-client NIC (decommissioned — remove DNS record after VM is destroyed) |
|
||||||
|
|
||||||
|
PTR records exist for all five. Remove `server.storage.home`, `server.sweet.home`,
|
||||||
|
and their PTRs from FreeIPA DNS once the server VM is destroyed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,10 +128,10 @@ All VMs and LXC containers run on pve1.
|
|||||||
|
|
||||||
| IP | Hostname | Role | Status |
|
| IP | Hostname | Role | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `192.168.2.229` | ha-vip | HA file server iSCSI floating VIP (Pacemaker) | Future |
|
| `192.168.2.229` | ha-vip-lan | HA file server LAN floating VIP (Pacemaker `vip-lan`) — LAN iSCSI + NFS | Active |
|
||||||
| `192.168.2.228` | ha-node1 | HA file server node 1 (DRBD + XFS + iSCSI) | Future |
|
| `192.168.2.228` | ha-node1 | HA file server node 1 — management NIC | Active |
|
||||||
| `192.168.2.227` | ha-node2 | HA file server node 2 (DRBD + XFS + iSCSI) | Future |
|
| `192.168.2.227` | ha-node2 | HA file server node 2 — management NIC | Active |
|
||||||
| `192.168.2.226` | server | Current NFS/ZFS file server — retires when HA is live | Retiring |
|
| `192.168.2.226` | server | Former NFS/ZFS file server — decommissioned | Removed from flake |
|
||||||
| `192.168.2.225` | docker | Docker / Traefik stack | Active |
|
| `192.168.2.225` | docker | Docker / Traefik stack | Active |
|
||||||
| `192.168.2.224` | nix-cache | Nix binary cache + remote builder | Active |
|
| `192.168.2.224` | nix-cache | Nix binary cache + remote builder | Active |
|
||||||
| `192.168.2.223` | pxe-boot | PXE / TFTP / HTTP netboot server | Active |
|
| `192.168.2.223` | pxe-boot | PXE / TFTP / HTTP netboot server | Active |
|
||||||
@@ -78,16 +150,40 @@ MAC address churn.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Storage network — 192.168.4.0/29
|
## Cluster network — VLAN 10 — 192.168.10.224/29
|
||||||
|
|
||||||
Internal to pve1 only. Proxmox bridge `vmbr1`, no physical NIC attached.
|
Internal to pve1 only. Proxmox bridge `vmbr1`, no physical NIC attached.
|
||||||
|
|
||||||
| IP | Hostname | Interface role |
|
| IP | Hostname | Interface role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `192.168.4.228` | ha-node1 | DRBD replication NIC |
|
| `192.168.10.228` | ha-node1 | DRBD replication + Corosync ring0 (primary heartbeat) |
|
||||||
| `192.168.4.227` | ha-node2 | DRBD replication NIC |
|
| `192.168.10.227` | ha-node2 | DRBD replication + Corosync ring0 (primary heartbeat) |
|
||||||
| — | no gateway | Isolated — not routed to LAN or internet |
|
| — | no gateway | Isolated — not routed to LAN or internet |
|
||||||
|
|
||||||
|
Corosync ring1 (backup heartbeat only) uses the LAN IPs (`192.168.2.228` / `192.168.2.227`)
|
||||||
|
over `vmbr0` — no additional bridge needed, and DRBD traffic never crosses ring1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage-client network — VLAN 20 — 192.168.20.0/24
|
||||||
|
|
||||||
|
Internal to pve1 only. Proxmox bridge `vmbr2`, no physical NIC attached.
|
||||||
|
|
||||||
|
| IP | Hostname | Interface / role |
|
||||||
|
|---|---|---|
|
||||||
|
| `192.168.20.229` | ha-vip-storage | Pacemaker floating VIP — NFS + iSCSI endpoint |
|
||||||
|
| `192.168.20.228` | ha-node1 | Storage-client NIC (ens20 / vmbr2) |
|
||||||
|
| `192.168.20.227` | ha-node2 | Storage-client NIC (ens20 / vmbr2) |
|
||||||
|
| `192.168.20.226` | server | Storage-client NIC (ens19 / vmbr2) — decommissioned |
|
||||||
|
| `192.168.20.225` | docker | Storage-client NIC (eth1 / vmbr2) — NFS client |
|
||||||
|
| — | no gateway | Isolated — not routed to LAN or internet |
|
||||||
|
|
||||||
|
NFS clients mount from `192.168.20.229` (surviving failover transparently via the VIP).
|
||||||
|
Firewall on each HA node restricts NFS and iSCSI ports to `192.168.20.0/24` — LAN hosts
|
||||||
|
cannot reach either service on this VIP. The `vip-storage` endpoint is not reachable
|
||||||
|
from the workstation directly (internal bridge only); health checks proxy through the
|
||||||
|
active HA node.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration reference
|
## Migration reference
|
||||||
@@ -101,9 +197,10 @@ Current → target IP for every host being renumbered.
|
|||||||
| pve1 | `192.168.2.250` | `192.168.2.245` | `/etc/network/interfaces` on Proxmox host |
|
| pve1 | `192.168.2.250` | `192.168.2.245` | `/etc/network/interfaces` on Proxmox host |
|
||||||
| pbs | `192.168.2.108` | `192.168.2.244` | static config on PBS host |
|
| pbs | `192.168.2.108` | `192.168.2.244` | static config on PBS host |
|
||||||
| nixos workstation | `192.168.2.119` | `192.168.2.243` | `networking.interfaces` / NetworkManager on guest |
|
| nixos workstation | `192.168.2.119` | `192.168.2.243` | `networking.interfaces` / NetworkManager on guest |
|
||||||
| ha-node1 | — | `192.168.2.228` | future |
|
| ha-node1 | — | `192.168.2.228` (LAN), `192.168.10.228` (cluster/VLAN 10), `192.168.20.228` (storage/VLAN 20) | active |
|
||||||
| ha-node2 | — | `192.168.2.227` | future |
|
| ha-node2 | — | `192.168.2.227` (LAN), `192.168.10.227` (cluster/VLAN 10), `192.168.20.227` (storage/VLAN 20) | active |
|
||||||
| ha-vip | — | `192.168.2.229` | future (Pacemaker resource) |
|
| ha-vip-lan | — | `192.168.2.229` (vmbr0 / Pacemaker `vip-lan`) — NFS endpoint for LAN clients | active |
|
||||||
|
| ha-vip-storage | — | `192.168.20.229` (vmbr2 / Pacemaker `vip-storage`) — iSCSI endpoint for VLAN 20 clients | active |
|
||||||
| server | `192.168.2.252` | `192.168.2.226` | static config on guest |
|
| server | `192.168.2.252` | `192.168.2.226` | static config on guest |
|
||||||
| docker | `192.168.2.249` | `192.168.2.225` | static config on guest |
|
| docker | `192.168.2.249` | `192.168.2.225` | static config on guest |
|
||||||
| nix-cache | `192.168.2.120` | `192.168.2.224` | static config on guest |
|
| nix-cache | `192.168.2.120` | `192.168.2.224` | static config on guest |
|
||||||
|
|||||||
+83
-1
@@ -336,7 +336,89 @@ temporarily use either.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 6 — Final cleanup
|
## Stage 6 — HA storage cutover (docker NFS remount)
|
||||||
|
|
||||||
|
> **Prerequisites:**
|
||||||
|
> - HA cluster fully deployed and `vip-storage` (`nfs.storage.home` → 192.168.20.229) serving NFS ✓
|
||||||
|
> - DNS configured: `storage.home` zone populated, `nfs.storage.home` resolves to 192.168.20.229 ✓
|
||||||
|
> - docker CT has eth1 on vmbr2 (`docker.storage.home` → 192.168.20.225) ✓
|
||||||
|
> - Final rsync from server.sweet.home to `/srv/ha-data` complete before step 6b
|
||||||
|
|
||||||
|
docker.sweet.home currently NFS-mounts its persistent volumes from `server.sweet.home`
|
||||||
|
(`192.168.2.226:/tank/docker/...`). This stage moves those mounts to the HA cluster's
|
||||||
|
storage VIP so server can be decommissioned.
|
||||||
|
|
||||||
|
### 6a. Final rsync from server to HA cluster
|
||||||
|
|
||||||
|
Run from server.sweet.home (or over SSH from the workstation) to sync any data written
|
||||||
|
since the initial rsync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Confirm active HA node and mount point
|
||||||
|
ssh wayne@192.168.2.228 'sudo findmnt /srv/ha-data' # check which node is active
|
||||||
|
|
||||||
|
# rsync each dataset (adjust source paths to match /tank layout on server)
|
||||||
|
sudo rsync -av --delete /tank/docker/config/ wayne@<active-node-ip>:/srv/ha-data/docker/config/
|
||||||
|
sudo rsync -av --delete /tank/docker/databases/ wayne@<active-node-ip>:/srv/ha-data/docker/databases/
|
||||||
|
sudo rsync -av --delete /tank/docker/volumes/ wayne@<active-node-ip>:/srv/ha-data/docker/volumes/
|
||||||
|
sudo rsync -av --delete /tank/docker/nextcloud-data/ wayne@<active-node-ip>:/srv/ha-data/docker/nextcloud-data/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6b. Update docker NixOS config to mount from vip-storage
|
||||||
|
|
||||||
|
In `hosts/docker/host.nix` (or wherever the NFS mount fileSystems are declared), change
|
||||||
|
the NFS server from `server.sweet.home` / `192.168.2.226` to `nfs.storage.home`:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# Before:
|
||||||
|
fileSystems."/mnt/docker/config" = {
|
||||||
|
device = "server:/tank/docker/config"; # or 192.168.2.226:...
|
||||||
|
...
|
||||||
|
};
|
||||||
|
|
||||||
|
# After:
|
||||||
|
fileSystems."/mnt/docker/config" = {
|
||||||
|
device = "nfs.storage.home:/srv/ha-data/docker/config";
|
||||||
|
...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the DNS name (`nfs.storage.home`) rather than the VIP IP means the mount
|
||||||
|
config survives a future VIP renumber without touching the NixOS config.
|
||||||
|
Repeat for all four docker shares (`config`, `databases`, `volumes`, `nextcloud-data`).
|
||||||
|
Then rebuild docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the workstation — or via Switch-nix on docker itself
|
||||||
|
sudo nixos-rebuild switch --no-write-lock-file --refresh \
|
||||||
|
--flake "git+https://gitea.lan.ddnsgeek.com/beatzaplenty/nixos.git#lxc-docker"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6c. Verify mounts and container health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh wayne@192.168.2.225 'findmnt | grep 192.168.20' # mounts should show vip-storage
|
||||||
|
ssh wayne@192.168.2.225 'docker ps' # all containers running
|
||||||
|
```
|
||||||
|
|
||||||
|
Spot-check Nextcloud, Traefik, and any database containers for connectivity.
|
||||||
|
|
||||||
|
### 6d. Decommission server.sweet.home
|
||||||
|
|
||||||
|
Once docker is confirmed healthy on the HA NFS mounts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop server VM on pve1
|
||||||
|
ssh wayne@192.168.2.245 'sudo qm stop 101'
|
||||||
|
|
||||||
|
# (Optional) Archive the ZFS pool snapshot before destroying
|
||||||
|
# Then after a settling period:
|
||||||
|
ssh wayne@192.168.2.245 'sudo qm destroy 101 --destroy-unreferenced-disks 1'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 7 — Final cleanup
|
||||||
|
|
||||||
Once all hosts are at their new IPs and verified:
|
Once all hosts are at their new IPs and verified:
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,9 @@
|
|||||||
# (hostName, hostId, per-machine secrets). Every build type except
|
# (hostName, hostId, per-machine secrets). Every build type except
|
||||||
# nix-cache itself consumes the nix-cache substituter and remote
|
# nix-cache itself consumes the nix-cache substituter and remote
|
||||||
# builder.
|
# builder.
|
||||||
mkTarget = { platform, buildType, hostPath, homeFile ? ./modules/common/home.nix }:
|
mkTarget = { platform, buildType, hostPath, homeFile ? ./modules/common/home.nix, nameSuffix ? "" }:
|
||||||
let
|
let
|
||||||
flakeTarget = "${platform}-${buildType}";
|
flakeTarget = "${platform}-${buildType}${nameSuffix}";
|
||||||
in
|
in
|
||||||
nixpkgs.lib.nixosSystem {
|
nixpkgs.lib.nixosSystem {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -109,9 +109,6 @@
|
|||||||
proxmox-nix-cache = mkTarget { platform = "proxmox"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
proxmox-nix-cache = mkTarget { platform = "proxmox"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
||||||
lxc-nix-cache = mkTarget { platform = "lxc"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
lxc-nix-cache = mkTarget { platform = "lxc"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
||||||
|
|
||||||
linode-server = mkTarget { platform = "linode"; buildType = "server"; hostPath = ./hosts/server/host.nix; };
|
|
||||||
proxmox-server = mkTarget { platform = "proxmox"; buildType = "server"; hostPath = ./hosts/server/host.nix; };
|
|
||||||
lxc-server = mkTarget { platform = "lxc"; buildType = "server"; hostPath = ./hosts/server/host.nix; };
|
|
||||||
|
|
||||||
linode-docker = mkTarget { platform = "linode"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
linode-docker = mkTarget { platform = "linode"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
||||||
proxmox-docker = mkTarget { platform = "proxmox"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
proxmox-docker = mkTarget { platform = "proxmox"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
||||||
@@ -131,8 +128,8 @@
|
|||||||
|
|
||||||
lxc-tor-relay = mkTarget { platform = "lxc"; buildType = "tor-relay"; hostPath = ./hosts/tor-relay/host.nix; };
|
lxc-tor-relay = mkTarget { platform = "lxc"; buildType = "tor-relay"; hostPath = ./hosts/tor-relay/host.nix; };
|
||||||
|
|
||||||
proxmox-ha-server-1 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-1/host.nix; };
|
proxmox-ha-server-1 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-1/host.nix; nameSuffix = "-1"; };
|
||||||
proxmox-ha-server-2 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-2/host.nix; };
|
proxmox-ha-server-2 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-2/host.nix; nameSuffix = "-2"; };
|
||||||
};
|
};
|
||||||
|
|
||||||
# Auto-install environments (migrated from the former nix-auto-installer
|
# Auto-install environments (migrated from the former nix-auto-installer
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
hostName = "docker";
|
hostName = "docker";
|
||||||
hostId = "007f0200";
|
hostId = "007f0200";
|
||||||
useDHCP = false;
|
useDHCP = false;
|
||||||
interfaces.${vars.vmLanInterface}.ipv4.addresses = [{
|
interfaces = {
|
||||||
address = vars.dockerIp;
|
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.dockerIp; prefixLength = vars.lanPrefixLength; }];
|
||||||
prefixLength = vars.lanPrefixLength;
|
${vars.lxcStorageInterface}.ipv4.addresses = [{ address = vars.dockerStorageIp; prefixLength = vars.haClientPrefixLength; }];
|
||||||
}];
|
};
|
||||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||||
nameservers = [ vars.domainControllerIp ];
|
nameservers = [ vars.domainControllerIp ];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,11 @@
|
|||||||
hostName = vars.haServer1Host;
|
hostName = vars.haServer1Host;
|
||||||
hostId = "3a4b5c6d";
|
hostId = "3a4b5c6d";
|
||||||
useDHCP = false;
|
useDHCP = false;
|
||||||
interfaces.${vars.vmLanInterface}.ipv4.addresses = [{
|
interfaces = {
|
||||||
address = vars.haServer1Ip;
|
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.haServer1Ip; prefixLength = vars.lanPrefixLength; }];
|
||||||
prefixLength = vars.lanPrefixLength;
|
${vars.vmStorageInterface}.ipv4.addresses = [{ address = vars.haServer1StorageIp; prefixLength = vars.haStoragePrefixLength; }];
|
||||||
}];
|
${vars.vmStorageClientInterface}.ipv4.addresses = [{ address = vars.haServer1ClientIp; prefixLength = vars.haClientPrefixLength; }];
|
||||||
interfaces.${vars.vmStorageInterface}.ipv4.addresses = [{
|
};
|
||||||
address = vars.haServer1StorageIp;
|
|
||||||
prefixLength = vars.haStoragePrefixLength;
|
|
||||||
}];
|
|
||||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||||
nameservers = [ vars.domainControllerIp ];
|
nameservers = [ vars.domainControllerIp ];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,11 @@
|
|||||||
hostName = vars.haServer2Host;
|
hostName = vars.haServer2Host;
|
||||||
hostId = "7e8f9a0b";
|
hostId = "7e8f9a0b";
|
||||||
useDHCP = false;
|
useDHCP = false;
|
||||||
interfaces.${vars.vmLanInterface}.ipv4.addresses = [{
|
interfaces = {
|
||||||
address = vars.haServer2Ip;
|
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.haServer2Ip; prefixLength = vars.lanPrefixLength; }];
|
||||||
prefixLength = vars.lanPrefixLength;
|
${vars.vmStorageInterface}.ipv4.addresses = [{ address = vars.haServer2StorageIp; prefixLength = vars.haStoragePrefixLength; }];
|
||||||
}];
|
${vars.vmStorageClientInterface}.ipv4.addresses = [{ address = vars.haServer2ClientIp; prefixLength = vars.haClientPrefixLength; }];
|
||||||
interfaces.${vars.vmStorageInterface}.ipv4.addresses = [{
|
};
|
||||||
address = vars.haServer2StorageIp;
|
|
||||||
prefixLength = vars.haStoragePrefixLength;
|
|
||||||
}];
|
|
||||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||||
nameservers = [ vars.domainControllerIp ];
|
nameservers = [ vars.domainControllerIp ];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{ vars, ... }:
|
|
||||||
|
|
||||||
{
|
|
||||||
networking = {
|
|
||||||
hostName = vars.nfsServerHost;
|
|
||||||
hostId = "6689f93e";
|
|
||||||
useDHCP = false;
|
|
||||||
interfaces.${vars.vmLanInterface}.ipv4.addresses = [{
|
|
||||||
address = vars.serverIp;
|
|
||||||
prefixLength = vars.lanPrefixLength;
|
|
||||||
}];
|
|
||||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
|
||||||
nameservers = [ vars.domainControllerIp ];
|
|
||||||
};
|
|
||||||
|
|
||||||
services.beszel.agent.environment = {
|
|
||||||
EXTRA_FILESYSTEMS = "${vars.storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
|
||||||
LOG_LEVEL = "debug";
|
|
||||||
};
|
|
||||||
|
|
||||||
# Preserved from the pre-refactor `server` target — stateVersion must never
|
|
||||||
# be bumped on an already-installed machine.
|
|
||||||
system.stateVersion = "25.05";
|
|
||||||
}
|
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
sops
|
sops
|
||||||
];
|
];
|
||||||
sessionVariables = {
|
sessionVariables = {
|
||||||
EDITOR = "vim";
|
EDITOR = "nano";
|
||||||
SOPS_AGE_KEY_FILE = "/home/${vars.ipaUser}/.config/sops/age/keys.txt";
|
SOPS_AGE_KEY_FILE = "/home/${vars.ipaUser}/.config/sops/age/keys.txt";
|
||||||
};
|
};
|
||||||
file = {
|
file = {
|
||||||
|
|||||||
@@ -17,12 +17,15 @@
|
|||||||
{ lib, pkgs, vars, ... }:
|
{ lib, pkgs, vars, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Generates /etc/exports lines for all nfsShares data entries. Shared
|
# Generates /etc/exports lines for all nfsShares data entries.
|
||||||
# pattern with modules/build-types/server.nix — both export the same
|
# LAN (VLAN 2): NFS via vip-lan (192.168.2.229) for pxe-boot and other LAN clients.
|
||||||
# set of shares, differing only in the storage root they serve from.
|
# Storage-client (VLAN 20): NFS via vip-storage (192.168.20.229) for docker and
|
||||||
|
# future swarm nodes; firewall restricts these ports to haClientCidr only.
|
||||||
mkNfsExports = storageRoot:
|
mkNfsExports = storageRoot:
|
||||||
lib.concatMapStrings
|
lib.concatMapStrings
|
||||||
(share: " ${storageRoot}/${share.subpath} ${vars.lanCidr}${vars.nfsShares.options}\n")
|
(share:
|
||||||
|
" ${storageRoot}/${share.subpath} ${vars.lanCidr}${vars.nfsShares.options}\n" +
|
||||||
|
" ${storageRoot}/${share.subpath} ${vars.haClientCidr}${vars.nfsShares.options}\n")
|
||||||
(lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares));
|
(lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares));
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
{ vars, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
poolName = lib.removePrefix "/" vars.storageRoot;
|
|
||||||
|
|
||||||
# For each NFS share subpath, generate every ancestor path so ZFS datasets
|
|
||||||
# are created parent-first. e.g. "docker/config" → ["docker" "docker/config"]
|
|
||||||
ancestors = path:
|
|
||||||
let parts = lib.splitString "/" path;
|
|
||||||
in lib.imap1 (i: _: lib.concatStringsSep "/" (lib.take i parts)) parts;
|
|
||||||
|
|
||||||
poolDatasets = lib.unique (
|
|
||||||
lib.concatMap (share: ancestors share.subpath)
|
|
||||||
(lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares))
|
|
||||||
);
|
|
||||||
|
|
||||||
# Generates /etc/exports lines for all nfsShares data entries (every
|
|
||||||
# attrset value — excludes the bare `options` string). Both server and
|
|
||||||
# ha-server export the same share set from different storage roots, so
|
|
||||||
# this helper is the single source of truth for the export line format.
|
|
||||||
mkNfsExports = storageRoot:
|
|
||||||
lib.concatMapStrings
|
|
||||||
(share: " ${storageRoot}/${share.subpath} ${vars.lanCidr}${vars.nfsShares.options}\n")
|
|
||||||
(lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares));
|
|
||||||
in
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
../beszel/enable-agent.nix
|
|
||||||
../services/zfs/enable-service.nix
|
|
||||||
];
|
|
||||||
|
|
||||||
boot.zfs.extraPools = [ poolName ];
|
|
||||||
|
|
||||||
# On a fresh image deploy the data disk (scsi1) starts blank — no pool
|
|
||||||
# exists yet, so zfs-import-tank.service would spin for 60 s and fail.
|
|
||||||
# This service runs first: if the pool is already present it exits instantly;
|
|
||||||
# otherwise it creates it (with all required datasets) so the standard
|
|
||||||
# import service finds it ready on the very first boot.
|
|
||||||
systemd.services."zfs-init-${poolName}" = {
|
|
||||||
description = "Initialize '${poolName}' ZFS pool on first boot if not present";
|
|
||||||
wantedBy = [ "zfs-import-${poolName}.service" ];
|
|
||||||
before = [ "zfs-import-${poolName}.service" ];
|
|
||||||
after = [ "systemd-udev-settle.service" ];
|
|
||||||
unitConfig.DefaultDependencies = false;
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
path = [ pkgs.zfs_unstable ];
|
|
||||||
script = ''
|
|
||||||
# Already imported — nothing to do.
|
|
||||||
if zpool list "${poolName}" >/dev/null 2>&1; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Locate the data disk first — used for both the fallback import
|
|
||||||
# attempt and, only if the disk is genuinely blank, pool creation.
|
|
||||||
DATA_DISK=""
|
|
||||||
for candidate in /dev/disk/by-id/scsi-*drive-scsi1; do
|
|
||||||
[[ "$candidate" == *-part* ]] && continue
|
|
||||||
[ -b "$candidate" ] && DATA_DISK="$candidate" && break
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$DATA_DISK" ]; then
|
|
||||||
echo "zfs-init-${poolName}: no data disk found (expected /dev/disk/by-id/scsi-*drive-scsi1)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try importing via the by-id symlink directory first (normal path),
|
|
||||||
# then fall back to scanning the disk directly. The two-step exists
|
|
||||||
# because of a udev race: systemd-udev-settle.service can clear before
|
|
||||||
# /dev/disk/by-id/ entries are fully populated, causing the first
|
|
||||||
# import to fail even when the pool is intact on the disk.
|
|
||||||
if zpool import -d /dev/disk/by-id -N "${poolName}" 2>/dev/null; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if zpool import -d "$DATA_DISK" -N "${poolName}" 2>/dev/null; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Both import attempts failed. Before creating a new pool, verify the
|
|
||||||
# disk is genuinely blank — if ZFS label metadata is present the import
|
|
||||||
# failed for some other reason and we must not clobber existing data.
|
|
||||||
if zdb -l "$DATA_DISK" 2>/dev/null | grep -q "name: '${poolName}'"; then
|
|
||||||
echo "zfs-init-${poolName}: $DATA_DISK has ZFS pool '${poolName}' metadata but import failed — refusing to overwrite existing data. Run 'zpool import -d $DATA_DISK ${poolName}' manually to investigate." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Disk is genuinely blank: create the pool. -f is intentionally
|
|
||||||
# omitted so that if we somehow reach this point with an existing pool
|
|
||||||
# on the disk, zpool refuses rather than silently destroying data.
|
|
||||||
echo "zfs-init-${poolName}: creating pool on $DATA_DISK"
|
|
||||||
zpool create "${poolName}" "$DATA_DISK"
|
|
||||||
${lib.concatMapStrings (ds: ''
|
|
||||||
zfs create "${poolName}/${ds}"
|
|
||||||
'') poolDatasets}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.nfs-server = {
|
|
||||||
after = [ "zfs-mount.service" ];
|
|
||||||
requires = [ "zfs-mount.service" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
# rpc-svcgssd handles Kerberos/GSS-API for NFS. Not needed: exports use
|
|
||||||
# standard auth, not sec=krb5. On IPA-joined hosts the keytab exists (host/
|
|
||||||
# principal only) but has no nfs/ principal, causing spurious failure.
|
|
||||||
# Mask it so nfs-server's Wants= can't pull it in.
|
|
||||||
systemd.services.rpc-svcgssd.enable = false;
|
|
||||||
|
|
||||||
services.nfs.server = {
|
|
||||||
enable = true;
|
|
||||||
exports = mkNfsExports vars.storageRoot;
|
|
||||||
};
|
|
||||||
|
|
||||||
# mountd (20048) is needed for showmount/NFSv3 mount protocol — without it
|
|
||||||
# clients can reach portmapper (111) and get the mountd port back, then
|
|
||||||
# time out trying to connect to it. All three ports need TCP and UDP.
|
|
||||||
networking.firewall.allowedTCPPorts = [ vars.ports.nfsRpcbind vars.ports.nfsd vars.ports.nfsMountd ];
|
|
||||||
networking.firewall.allowedUDPPorts = [ vars.ports.nfsRpcbind vars.ports.nfsd vars.ports.nfsMountd ];
|
|
||||||
}
|
|
||||||
@@ -10,24 +10,19 @@ let
|
|||||||
# non-blocking behavior, so they don't need `nofail` too).
|
# non-blocking behavior, so they don't need `nofail` too).
|
||||||
automountOpts = if config.boot.isContainer then [ "nofail" ] else [ "x-systemd.automount" ];
|
automountOpts = if config.boot.isContainer then [ "nofail" ] else [ "x-systemd.automount" ];
|
||||||
|
|
||||||
# A bare hostname here never resolves reliably: systemd-resolved only
|
# FQDN in the storage.home zone — resolves to the Pacemaker vip-storage
|
||||||
# ever tries LLMNR for single-label names (never DNS, regardless of any
|
# (192.168.20.229) on docker's eth1/vmbr2 interface. Using the DNS name
|
||||||
# configured search domain), and a *global* search domain (the first fix
|
# rather than the raw IP means a future VIP renumber only requires a DNS
|
||||||
# attempted here) backfires worse -- confirmed live on lxc-docker, adding
|
# update, not a NixOS rebuild. The storage.home zone is served by the same
|
||||||
# `networking.search` made systemd-resolved prioritize its domain-matched
|
# FreeIPA nameserver (domainControllerIp) that docker already uses, so
|
||||||
# but server-less global scope over eth0's correctly-configured one for
|
# resolution reaches it over eth0 without any extra routing.
|
||||||
# every "*.sweet.home" query, silently sending them to public fallback
|
nfsServer = vars.haStorageNfsFqdn;
|
||||||
# DNS instead. `resolvectl query --interface=eth0 server.sweet.home`
|
storageRoot = vars.haStorageRoot;
|
||||||
# resolved fine throughout, proving the LAN DNS server was never the
|
|
||||||
# problem -- only the ambient, unqualified device string was. Using the
|
|
||||||
# FQDN directly sidesteps all of that, matching the pattern
|
|
||||||
# ../raspi/mount-data.nix already uses for the same reason.
|
|
||||||
nfsServer = "${vars.nfsServerHost}.${vars.homeDomain}";
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
fileSystems = {
|
fileSystems = {
|
||||||
${vars.nfsShares.dockerConfig.mountpoint} = {
|
${vars.nfsShares.dockerConfig.mountpoint} = {
|
||||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.dockerConfig.subpath}";
|
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerConfig.subpath}";
|
||||||
fsType = "nfs";
|
fsType = "nfs";
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
@@ -38,7 +33,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
${vars.nfsShares.dockerDatabases.mountpoint} = {
|
${vars.nfsShares.dockerDatabases.mountpoint} = {
|
||||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.dockerDatabases.subpath}";
|
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerDatabases.subpath}";
|
||||||
fsType = "nfs";
|
fsType = "nfs";
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
@@ -49,7 +44,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
${vars.nfsShares.dockerVolumes.mountpoint} = {
|
${vars.nfsShares.dockerVolumes.mountpoint} = {
|
||||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
||||||
fsType = "nfs";
|
fsType = "nfs";
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
@@ -60,7 +55,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
${vars.nfsShares.nextcloudData.mountpoint} = {
|
${vars.nfsShares.nextcloudData.mountpoint} = {
|
||||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.nextcloudData.subpath}";
|
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.nextcloudData.subpath}";
|
||||||
fsType = "nfs";
|
fsType = "nfs";
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
@@ -71,7 +66,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
${vars.nfsShares.raspiVolumes.mountpoint} = {
|
${vars.nfsShares.raspiVolumes.mountpoint} = {
|
||||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.raspiVolumes.subpath}";
|
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.raspiVolumes.subpath}";
|
||||||
fsType = "nfs";
|
fsType = "nfs";
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ in
|
|||||||
# Pacemaker's OCF drbd agent calls drbdadm up/down directly when managing
|
# Pacemaker's OCF drbd agent calls drbdadm up/down directly when managing
|
||||||
# the resource. If drbd.service also runs drbdadm up all while DRBD is
|
# the resource. If drbd.service also runs drbdadm up all while DRBD is
|
||||||
# already Primary under Pacemaker, apply-al fails with "device busy" (exit 20).
|
# already Primary under Pacemaker, apply-al fails with "device busy" (exit 20).
|
||||||
systemd.services.drbd.wantedBy = lib.mkForce [];
|
systemd.services.drbd.wantedBy = lib.mkForce [ ];
|
||||||
|
|
||||||
services.drbd = {
|
services.drbd = {
|
||||||
enable = true;
|
enable = true;
|
||||||
@@ -122,33 +122,47 @@ in
|
|||||||
services.corosync = {
|
services.corosync = {
|
||||||
clusterName = "ha-cluster";
|
clusterName = "ha-cluster";
|
||||||
nodelist = [
|
nodelist = [
|
||||||
{ nodeid = 1; name = vars.haServer1Host; ring_addrs = [ vars.haServer1StorageIp ]; }
|
# ring0: cluster-internal vmbr1 (primary heartbeat + DRBD path)
|
||||||
{ nodeid = 2; name = vars.haServer2Host; ring_addrs = [ vars.haServer2StorageIp ]; }
|
# ring1: LAN vmbr0 (backup heartbeat only — never carries DRBD)
|
||||||
|
{ nodeid = 1; name = vars.haServer1Host; ring_addrs = [ vars.haServer1StorageIp vars.haServer1Ip ]; }
|
||||||
|
{ nodeid = 2; name = vars.haServer2Host; ring_addrs = [ vars.haServer2StorageIp vars.haServer2Ip ]; }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall = {
|
networking.firewall = {
|
||||||
allowedTCPPorts = [
|
allowedTCPPorts = [
|
||||||
vars.ports.haServerIscsi
|
|
||||||
vars.ports.haServerPacemakerRemoted
|
vars.ports.haServerPacemakerRemoted
|
||||||
vars.ports.haServerPcsd
|
vars.ports.haServerPcsd
|
||||||
vars.ports.haServerDrbd
|
vars.ports.haServerDrbd
|
||||||
vars.ports.nfsRpcbind
|
|
||||||
vars.ports.nfsd
|
|
||||||
vars.ports.nfsMountd
|
|
||||||
];
|
];
|
||||||
allowedUDPPorts = [
|
allowedUDPPorts = [
|
||||||
vars.ports.haServerCorosync1
|
vars.ports.haServerCorosync1
|
||||||
vars.ports.haServerCorosync2
|
vars.ports.haServerCorosync2
|
||||||
vars.ports.haServerCorosyncCrypto
|
vars.ports.haServerCorosyncCrypto
|
||||||
vars.ports.nfsRpcbind
|
|
||||||
vars.ports.nfsd
|
|
||||||
vars.ports.nfsMountd
|
|
||||||
];
|
];
|
||||||
|
# Protocol separation: iSCSI (VLAN 20 / storage clients only),
|
||||||
|
# NFS (VLAN 2 / LAN only). Cluster-internal subnets accepted wholesale
|
||||||
|
# since they are isolated bridges with no external uplink.
|
||||||
extraCommands = ''
|
extraCommands = ''
|
||||||
iptables -A INPUT -s ${vars.haServer1Ip}/32 -j ACCEPT
|
iptables -A nixos-fw -s ${vars.haServer1Ip}/32 -j nixos-fw-accept
|
||||||
iptables -A INPUT -s ${vars.haServer2Ip}/32 -j ACCEPT
|
iptables -A nixos-fw -s ${vars.haServer2Ip}/32 -j nixos-fw-accept
|
||||||
iptables -A INPUT -s ${vars.haStorageCidr} -j ACCEPT
|
iptables -A nixos-fw -s ${vars.haStorageCidr} -j nixos-fw-accept
|
||||||
|
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.haServerIscsi} -j nixos-fw-accept
|
||||||
|
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p udp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p udp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p udp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||||
|
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p udp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p udp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||||
|
iptables -A nixos-fw -p udp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{ config, lib, vars, ... }:
|
{ config, lib, vars, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Use the same FQDN approach as docker/mount-data.nix — a bare hostname is
|
# FQDN of the LAN NFS VIP (Pacemaker vip-lan, 192.168.2.229). Using the
|
||||||
# unreliable: systemd-resolved only tries LLMNR for single-label names, and
|
# FQDN rather than a raw IP or bare hostname avoids systemd-resolved LLMNR
|
||||||
# a global search domain causes it to skip the interface-scoped LAN DNS.
|
# quirks and survives a future VIP renumber via a DNS-only update.
|
||||||
nfsServer = "${vars.nfsServerHost}.${vars.homeDomain}";
|
nfsServer = "ha-vip-lan.${vars.homeDomain}";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
fileSystems.${vars.nfsShares.pxebootImages.mountpoint} = {
|
fileSystems.${vars.nfsShares.pxebootImages.mountpoint} = {
|
||||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.pxebootImages.subpath}";
|
device = "${nfsServer}:${vars.haStorageRoot}/${vars.nfsShares.pxebootImages.subpath}";
|
||||||
fsType = "nfs";
|
fsType = "nfs";
|
||||||
options = [
|
options = [
|
||||||
"_netdev"
|
"_netdev"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ NODE1="${NODE1:-ha-server-1}"
|
|||||||
NODE2="${NODE2:-ha-server-2}"
|
NODE2="${NODE2:-ha-server-2}"
|
||||||
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
||||||
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
||||||
VIP="${VIP:-192.168.2.229}" # vars.haServerVip
|
VIP="${VIP:-192.168.20.229}" # vars.haServerVip
|
||||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
||||||
ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn
|
ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+30
-14
@@ -26,7 +26,8 @@ NODE1="${NODE1:-ha-server-1}"
|
|||||||
NODE2="${NODE2:-ha-server-2}"
|
NODE2="${NODE2:-ha-server-2}"
|
||||||
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
||||||
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
||||||
VIP="${VIP:-192.168.2.229}" # vars.haServerVip
|
VIP="${VIP:-192.168.20.229}" # vars.haServerVip (storage-client, vmbr2, VLAN 20)
|
||||||
|
VIP_LAN="${VIP_LAN:-192.168.2.229}" # vars.haServerLanVip (LAN, vmbr0)
|
||||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
||||||
ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn
|
ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn
|
||||||
ISCSI_LUN_FILE="${XFS_MOUNT}/iscsi-lun.img"
|
ISCSI_LUN_FILE="${XFS_MOUNT}/iscsi-lun.img"
|
||||||
@@ -416,15 +417,28 @@ cibadmin --replace --scope resources --xml-text '<resources>
|
|||||||
<op id="nfs-monitor" name="monitor" interval="30s" timeout="40s"/>
|
<op id="nfs-monitor" name="monitor" interval="30s" timeout="40s"/>
|
||||||
</operations>
|
</operations>
|
||||||
</primitive>
|
</primitive>
|
||||||
<primitive id="vip" class="ocf" type="IPaddr2" provider="heartbeat">
|
<primitive id="vip-storage" class="ocf" type="IPaddr2" provider="heartbeat">
|
||||||
<instance_attributes id="vip-attrs">
|
<instance_attributes id="vip-storage-attrs">
|
||||||
<nvpair id="vip-ip" name="ip" value="192.168.2.229"/>
|
<nvpair id="vip-storage-ip" name="ip" value="192.168.20.229"/>
|
||||||
<nvpair id="vip-cidr" name="cidr_netmask" value="24"/>
|
<nvpair id="vip-storage-cidr" name="cidr_netmask" value="24"/>
|
||||||
|
<nvpair id="vip-storage-nic" name="nic" value="ens20"/>
|
||||||
</instance_attributes>
|
</instance_attributes>
|
||||||
<operations>
|
<operations>
|
||||||
<op id="vip-start" name="start" interval="0" timeout="20s"/>
|
<op id="vip-storage-start" name="start" interval="0" timeout="20s"/>
|
||||||
<op id="vip-stop" name="stop" interval="0" timeout="20s"/>
|
<op id="vip-storage-stop" name="stop" interval="0" timeout="20s"/>
|
||||||
<op id="vip-monitor" name="monitor" interval="10s" timeout="20s"/>
|
<op id="vip-storage-monitor" name="monitor" interval="10s" timeout="20s"/>
|
||||||
|
</operations>
|
||||||
|
</primitive>
|
||||||
|
<primitive id="vip-lan" class="ocf" type="IPaddr2" provider="heartbeat">
|
||||||
|
<instance_attributes id="vip-lan-attrs">
|
||||||
|
<nvpair id="vip-lan-ip" name="ip" value="192.168.2.229"/>
|
||||||
|
<nvpair id="vip-lan-cidr" name="cidr_netmask" value="24"/>
|
||||||
|
<nvpair id="vip-lan-nic" name="nic" value="ens18"/>
|
||||||
|
</instance_attributes>
|
||||||
|
<operations>
|
||||||
|
<op id="vip-lan-start" name="start" interval="0" timeout="20s"/>
|
||||||
|
<op id="vip-lan-stop" name="stop" interval="0" timeout="20s"/>
|
||||||
|
<op id="vip-lan-monitor" name="monitor" interval="10s" timeout="20s"/>
|
||||||
</operations>
|
</operations>
|
||||||
</primitive>
|
</primitive>
|
||||||
</group>
|
</group>
|
||||||
@@ -441,11 +455,11 @@ crm_resource --cleanup 2>/dev/null || true
|
|||||||
|
|
||||||
log "Waiting for resources to start..."
|
log "Waiting for resources to start..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if crm_resource -r vip --locate 2>/dev/null | grep -q "running on"; then
|
if crm_resource -r vip-storage --locate 2>/dev/null | grep -q "running on"; then
|
||||||
log "VIP is up: $(crm_resource -r vip --locate)"
|
log "VIPs are up: $(crm_resource -r vip-storage --locate)"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
[[ $i -eq 60 ]] && { warn "VIP not up after 120 s — check: crm_mon -1"; break; }
|
[[ $i -eq 60 ]] && { warn "VIPs not up after 120 s — check: crm_mon -1"; break; }
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -453,9 +467,11 @@ log ""
|
|||||||
log "═══════════════════════════════════════════════════════════════"
|
log "═══════════════════════════════════════════════════════════════"
|
||||||
log " HA cluster initialised."
|
log " HA cluster initialised."
|
||||||
log ""
|
log ""
|
||||||
log " crm_mon -1 — cluster status"
|
log " crm_mon -1 — cluster status"
|
||||||
log " iscsiadm -m discovery -t st -p ${VIP} — verify iSCSI target"
|
log " iscsiadm -m discovery -t st -p ${VIP} — verify iSCSI (storage net)"
|
||||||
log " showmount -e ${VIP} — verify NFS exports"
|
log " iscsiadm -m discovery -t st -p ${VIP_LAN} — verify iSCSI (LAN)"
|
||||||
|
log " showmount -e ${VIP} — verify NFS exports (storage net)"
|
||||||
|
log " showmount -e ${VIP_LAN} — verify NFS exports (LAN)"
|
||||||
log ""
|
log ""
|
||||||
log " To enable STONITH (after deploying fence SSH key):"
|
log " To enable STONITH (after deploying fence SSH key):"
|
||||||
log " 1. Fill in VMID_NODE1 / VMID_NODE2 in cluster-enable-stonith.sh"
|
log " 1. Fill in VMID_NODE1 / VMID_NODE2 in cluster-enable-stonith.sh"
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ NODE1_HOST="ha-server-1"
|
|||||||
NODE2_HOST="ha-server-2"
|
NODE2_HOST="ha-server-2"
|
||||||
NODE1_IP="192.168.2.228"
|
NODE1_IP="192.168.2.228"
|
||||||
NODE2_IP="192.168.2.227"
|
NODE2_IP="192.168.2.227"
|
||||||
STORAGE_IP1="192.168.4.228"
|
STORAGE_IP1="192.168.10.228"
|
||||||
STORAGE_IP2="192.168.4.227"
|
STORAGE_IP2="192.168.10.227"
|
||||||
STORAGE_CIDR="192.168.4.0/29"
|
STORAGE_CIDR="192.168.10.224/29"
|
||||||
SSH_USER="${PROXMOX_SSH_USER:-wayne}"
|
SSH_USER="${PROXMOX_SSH_USER:-wayne}"
|
||||||
|
|
||||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||||
@@ -452,7 +452,7 @@ if ! $SKIP_CLUSTER_INIT; then
|
|||||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
|
||||||
"sudo env NODE1=${NODE1_HOST} NODE2=${NODE2_HOST} \
|
"sudo env NODE1=${NODE1_HOST} NODE2=${NODE2_HOST} \
|
||||||
NODE1_IP=${NODE1_IP} NODE2_IP=${NODE2_IP} \
|
NODE1_IP=${NODE1_IP} NODE2_IP=${NODE2_IP} \
|
||||||
VIP=192.168.2.229 XFS_MOUNT=/srv/ha-data \
|
VIP=192.168.20.229 XFS_MOUNT=/srv/ha-data \
|
||||||
ISCSI_IQN=iqn.2026-01.home.sweet:ha-storage \
|
ISCSI_IQN=iqn.2026-01.home.sweet:ha-storage \
|
||||||
VMID_NODE1=${VMID1} VMID_NODE2=${VMID2} \
|
VMID_NODE1=${VMID1} VMID_NODE2=${VMID2} \
|
||||||
HA_USER=${HA_USER} HA_KEY=/root/.ssh/cluster-init-key \
|
HA_USER=${HA_USER} HA_KEY=/root/.ssh/cluster-init-key \
|
||||||
@@ -491,7 +491,7 @@ if ! $SKIP_TESTS; then
|
|||||||
else
|
else
|
||||||
NODE1="$NODE1_HOST" NODE2="$NODE2_HOST" \
|
NODE1="$NODE1_HOST" NODE2="$NODE2_HOST" \
|
||||||
NODE1_IP="$NODE1_IP" NODE2_IP="$NODE2_IP" \
|
NODE1_IP="$NODE1_IP" NODE2_IP="$NODE2_IP" \
|
||||||
VIP="192.168.2.229" \
|
VIP="192.168.20.229" \
|
||||||
bash "${REPO_ROOT}/scripts/ha/acceptance-tests.sh"
|
bash "${REPO_ROOT}/scripts/ha/acceptance-tests.sh"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ NODE1="${NODE1:-ha-server-1}"
|
|||||||
NODE2="${NODE2:-ha-server-2}"
|
NODE2="${NODE2:-ha-server-2}"
|
||||||
NODE1_IP="${NODE1_IP:-192.168.2.228}"
|
NODE1_IP="${NODE1_IP:-192.168.2.228}"
|
||||||
NODE2_IP="${NODE2_IP:-192.168.2.227}"
|
NODE2_IP="${NODE2_IP:-192.168.2.227}"
|
||||||
VIP="${VIP:-192.168.2.229}"
|
VIP="${VIP:-192.168.20.229}"
|
||||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}"
|
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}"
|
||||||
HA_USER="${HA_USER:-nixos}"
|
HA_USER="${HA_USER:-nixos}"
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+42
-16
@@ -11,7 +11,8 @@ NODE1="${NODE1:-ha-server-1}"
|
|||||||
NODE2="${NODE2:-ha-server-2}"
|
NODE2="${NODE2:-ha-server-2}"
|
||||||
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
||||||
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
||||||
VIP="${VIP:-192.168.2.229}" # vars.haServerVip
|
VIP="${VIP:-192.168.20.229}" # vars.haServerVip (storage-client, VLAN 20 — internal only)
|
||||||
|
VIP_LAN="${VIP_LAN:-192.168.2.229}" # vars.haServerLanVip (LAN, VLAN 2 — reachable from workstation)
|
||||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
||||||
HA_USER="${HA_USER:-nixos}"
|
HA_USER="${HA_USER:-nixos}"
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
@@ -149,25 +150,50 @@ check_mount() {
|
|||||||
$REACHABLE_1 && check_mount "$NODE1" n1 || echo " $NODE1 [OFFLINE]"
|
$REACHABLE_1 && check_mount "$NODE1" n1 || echo " $NODE1 [OFFLINE]"
|
||||||
$REACHABLE_2 && check_mount "$NODE2" n2 || echo " $NODE2 [OFFLINE]"
|
$REACHABLE_2 && check_mount "$NODE2" n2 || echo " $NODE2 [OFFLINE]"
|
||||||
|
|
||||||
# ── Service ports via VIP ──────────────────────────────────────────────────
|
# ── LAN VIP (NFS) — reachable from workstation ────────────────────────────────
|
||||||
section "Services via VIP ($VIP)"
|
section "LAN VIP ($VIP_LAN) — NFS"
|
||||||
|
if ping -c1 -W2 "$VIP_LAN" >/dev/null 2>&1; then
|
||||||
check_port() {
|
|
||||||
local name=$1 port=$2
|
|
||||||
if bash -c "echo >/dev/tcp/${VIP}/${port}" 2>/dev/null; then
|
|
||||||
printf " %-10s port %-5s OK\n" "$name" "$port"
|
|
||||||
else
|
|
||||||
printf " %-10s port %-5s UNREACHABLE\n" "$name" "$port"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if ping -c1 -W2 "$VIP" >/dev/null 2>&1; then
|
|
||||||
echo " Ping OK"
|
echo " Ping OK"
|
||||||
else
|
else
|
||||||
echo " Ping UNREACHABLE"
|
echo " Ping UNREACHABLE"
|
||||||
fi
|
fi
|
||||||
check_port "NFS" 2049
|
if bash -c "echo >/dev/tcp/${VIP_LAN}/2049" 2>/dev/null; then
|
||||||
check_port "iSCSI" 3260
|
printf " %-10s port %-5s OK\n" "NFS" "2049"
|
||||||
|
else
|
||||||
|
printf " %-10s port %-5s UNREACHABLE\n" "NFS" "2049"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Storage VIP (NFS + iSCSI) — VLAN 20 internal bridge, tested via active node ─
|
||||||
|
section "Storage VIP ($VIP) — NFS + iSCSI (via ${ACTIVE_NODE:-unknown})"
|
||||||
|
|
||||||
|
run_active_raw() {
|
||||||
|
local active_ip=""
|
||||||
|
[[ "$ACTIVE_NODE" == "$NODE1" ]] && active_ip="$NODE1_IP"
|
||||||
|
[[ "$ACTIVE_NODE" == "$NODE2" ]] && active_ip="$NODE2_IP"
|
||||||
|
[[ -z "$active_ip" ]] && return 1
|
||||||
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||||
|
"${HA_USER}@${active_ip}" "$@" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||||
|
echo " Cannot determine active node — skipping"
|
||||||
|
else
|
||||||
|
if run_active_raw "ping -c1 -W2 '$VIP' >/dev/null 2>&1"; then
|
||||||
|
echo " Ping OK"
|
||||||
|
else
|
||||||
|
echo " Ping UNREACHABLE"
|
||||||
|
fi
|
||||||
|
if run_active_raw "bash -c 'echo >/dev/tcp/${VIP}/2049' 2>/dev/null"; then
|
||||||
|
printf " %-10s port %-5s OK\n" "NFS" "2049"
|
||||||
|
else
|
||||||
|
printf " %-10s port %-5s UNREACHABLE\n" "NFS" "2049"
|
||||||
|
fi
|
||||||
|
if run_active_raw "bash -c 'echo >/dev/tcp/${VIP}/3260' 2>/dev/null"; then
|
||||||
|
printf " %-10s port %-5s OK\n" "iSCSI" "3260"
|
||||||
|
else
|
||||||
|
printf " %-10s port %-5s UNREACHABLE\n" "iSCSI" "3260"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ DATA_DISK_SLOT="${DATA_DISK_SLOT:-scsi1}" # Proxmox disk name (scsi1 = data di
|
|||||||
HA_USER="${HA_USER:-nixos}"
|
HA_USER="${HA_USER:-nixos}"
|
||||||
PVE_HOST="${PVE_HOST:-${PVE1_HOST}}"
|
PVE_HOST="${PVE_HOST:-${PVE1_HOST}}"
|
||||||
PVE_SSH_USER="${PVE_SSH_USER:-${PROXMOX_SSH_USER}}"
|
PVE_SSH_USER="${PVE_SSH_USER:-${PROXMOX_SSH_USER}}"
|
||||||
|
PVE_SUDO=""
|
||||||
|
[[ "$PVE_SSH_USER" != "root" ]] && PVE_SUDO="sudo"
|
||||||
# By-id symlink for the data disk; basename resolves to the raw block device.
|
# By-id symlink for the data disk; basename resolves to the raw block device.
|
||||||
# matches variables.nix's haServerDrbdDisk.
|
# matches variables.nix's haServerDrbdDisk.
|
||||||
DATA_DISK_BYID="${DATA_DISK_BYID:-scsi-0QEMU_QEMU_HARDDISK_drive-${DATA_DISK_SLOT}}"
|
DATA_DISK_BYID="${DATA_DISK_BYID:-scsi-0QEMU_QEMU_HARDDISK_drive-${DATA_DISK_SLOT}}"
|
||||||
@@ -140,7 +142,7 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Looking up VM IDs on ${PVE_HOST}..."
|
echo "Looking up VM IDs on ${PVE_HOST}..."
|
||||||
|
|
||||||
QM_LIST=$(pve "qm list 2>/dev/null" || true)
|
QM_LIST=$(pve "$PVE_SUDO qm list 2>/dev/null" || true)
|
||||||
VMID1=$(echo "$QM_LIST" | awk -v name="$NODE1" '$0 ~ name {print $1}' | head -1)
|
VMID1=$(echo "$QM_LIST" | awk -v name="$NODE1" '$0 ~ name {print $1}' | head -1)
|
||||||
VMID2=$(echo "$QM_LIST" | awk -v name="$NODE2" '$0 ~ name {print $1}' | head -1)
|
VMID2=$(echo "$QM_LIST" | awk -v name="$NODE2" '$0 ~ name {print $1}' | head -1)
|
||||||
|
|
||||||
@@ -182,12 +184,12 @@ echo "── Phase 1 — Proxmox disk resize (${DATA_DISK_SLOT} ${SIZE} on both
|
|||||||
|
|
||||||
echo " ${DRY_PREFIX}qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE} ($NODE1 on ${PVE_HOST})"
|
echo " ${DRY_PREFIX}qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE} ($NODE1 on ${PVE_HOST})"
|
||||||
if ! $DRY_RUN; then
|
if ! $DRY_RUN; then
|
||||||
pve "qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE}"
|
pve "$PVE_SUDO qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " ${DRY_PREFIX}qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE} ($NODE2 on ${PVE_HOST})"
|
echo " ${DRY_PREFIX}qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE} ($NODE2 on ${PVE_HOST})"
|
||||||
if ! $DRY_RUN; then
|
if ! $DRY_RUN; then
|
||||||
pve "qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE}"
|
pve "$PVE_SUDO qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Phase 1 done."
|
echo " Phase 1 done."
|
||||||
@@ -202,17 +204,13 @@ rescan_node() {
|
|||||||
local node_name=$1 run_fn=$2
|
local node_name=$1 run_fn=$2
|
||||||
|
|
||||||
# Resolve block device name from the stable by-id symlink on the guest.
|
# Resolve block device name from the stable by-id symlink on the guest.
|
||||||
|
# Read-only lookup — safe to run even in dry-run so we show the real device.
|
||||||
local blk_dev=""
|
local blk_dev=""
|
||||||
if ! $DRY_RUN; then
|
blk_dev=$($run_fn "bash -c 'basename \$(readlink -f /dev/disk/by-id/${DATA_DISK_BYID})'" 2>/dev/null || true)
|
||||||
blk_dev=$($run_fn "bash -c 'basename \$(readlink -f /dev/disk/by-id/${DATA_DISK_BYID})'" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
if [[ -z "$blk_dev" ]]; then
|
if [[ -z "$blk_dev" ]]; then
|
||||||
# Fall back: scsi1 → index 1 → sdb, scsi2 → sdc, etc.
|
echo " ERROR: /dev/disk/by-id/${DATA_DISK_BYID} not found on $node_name" >&2
|
||||||
local slot_idx
|
echo " Check DATA_DISK_BYID or DATA_DISK_SLOT configuration." >&2
|
||||||
slot_idx=$(echo "$DATA_DISK_SLOT" | grep -oE '[0-9]+$' || echo "1")
|
exit 1
|
||||||
blk_dev=$(printf "sd%s" "$(echo "abcdefghij" | cut -c$((slot_idx + 1)))")
|
|
||||||
echo " WARNING: could not resolve /dev/disk/by-id/${DATA_DISK_BYID} on $node_name;" \
|
|
||||||
"falling back to /dev/${blk_dev}" >&2
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " ${DRY_PREFIX}Rescanning /dev/${blk_dev} on ${node_name}..."
|
echo " ${DRY_PREFIX}Rescanning /dev/${blk_dev} on ${node_name}..."
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
root-hashedPassword: ENC[AES256_GCM,data:Kp0nOZI7vDoLhJHiOJBwJn0rQZ5yhnwapGnAcA+qh8vlDETtFs/iQdetF/2ZxmANf62SviTNd+Ag0q5JIF1996x7onZGXqxgSMCuVzZLBdUlsO5IR0BslWWz47khYGTe4WkUg4NB1itBfQ==,iv:5Sra5vJ79V8hxQT3g9qJ+dOj2W2sumIhqpitqnHjJdk=,tag:3Igu0+8GeUZHqS3fKUVwog==,type:str]
|
root-hashedPassword: ENC[AES256_GCM,data:Kp0nOZI7vDoLhJHiOJBwJn0rQZ5yhnwapGnAcA+qh8vlDETtFs/iQdetF/2ZxmANf62SviTNd+Ag0q5JIF1996x7onZGXqxgSMCuVzZLBdUlsO5IR0BslWWz47khYGTe4WkUg4NB1itBfQ==,iv:5Sra5vJ79V8hxQT3g9qJ+dOj2W2sumIhqpitqnHjJdk=,tag:3Igu0+8GeUZHqS3fKUVwog==,type:str]
|
||||||
nixos-hashedPassword: ENC[AES256_GCM,data:pT7tVRN6X4a+DNUgB7fIUUE3CbnetkjxmoSL1PxSU+ktsFU+fB0mEvJjA1uujsGH5Rcztg7YM815+M0Z67ILmHaXbza5DtFacrqhi4/b277xly0SHRX4yOvBwQh6mJG1jn/0O/wvUUIYdw==,iv:bp2nfhC8nFbk6o5iWDAugvbzu7J/a1xayFnBEtkhNpE=,tag:HqWgkIpSrSM/K9OK2WO+VQ==,type:str]
|
nixos-hashedPassword: ENC[AES256_GCM,data:pT7tVRN6X4a+DNUgB7fIUUE3CbnetkjxmoSL1PxSU+ktsFU+fB0mEvJjA1uujsGH5Rcztg7YM815+M0Z67ILmHaXbza5DtFacrqhi4/b277xly0SHRX4yOvBwQh6mJG1jn/0O/wvUUIYdw==,iv:bp2nfhC8nFbk6o5iWDAugvbzu7J/a1xayFnBEtkhNpE=,tag:HqWgkIpSrSM/K9OK2WO+VQ==,type:str]
|
||||||
nix-github-token: ENC[AES256_GCM,data:k1vYz7SqVhzpWa6jTL6NUD8lKOCpHCgTm+HT4IcnbzbSTUZP/bJUYw==,iv:UqAULZnr/4+VcioUDfTwvOSuwM8K9JgGhiApvYQPyoc=,tag:1LKHXhWAO/AHPDIZFBb04A==,type:str]
|
nix-github-token: ENC[AES256_GCM,data:k1vYz7SqVhzpWa6jTL6NUD8lKOCpHCgTm+HT4IcnbzbSTUZP/bJUYw==,iv:UqAULZnr/4+VcioUDfTwvOSuwM8K9JgGhiApvYQPyoc=,tag:1LKHXhWAO/AHPDIZFBb04A==,type:str]
|
||||||
beszel-token: ENC[AES256_GCM,data:ds7OFjIXpOe/OIiEIydK9qsYkq3rMShK+jCTRRHKLzxUV0Bl,iv:nEt5FxkQaiTmAPFbj7vGJIMAEBjXmx+XYcdqaAxGzo8=,tag:+ql/UsgBR+TPySG9VDZi1g==,type:str]
|
beszel-token: ENC[AES256_GCM,data:OWmSRkZjb11y0Y8GdobqiE9GFwzdHOvvxCbYx69qUghGYARN,iv:i/JhGH0O7ThxPkL0SLAjfN0Fq8prm7tybI5kF2NRNpw=,tag:dBcqxOSHTnD4xngpOog55Q==,type:str]
|
||||||
sops:
|
sops:
|
||||||
age:
|
age:
|
||||||
- enc: |
|
- enc: |
|
||||||
@@ -229,7 +229,7 @@ sops:
|
|||||||
BBYPJMn3lfDgiQPvfXINfhJI6O+bWyjt0WnfodCGFM6EJLARXiTaqA==
|
BBYPJMn3lfDgiQPvfXINfhJI6O+bWyjt0WnfodCGFM6EJLARXiTaqA==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r
|
recipient: age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r
|
||||||
lastmodified: "2026-07-29T01:49:58Z"
|
lastmodified: "2026-07-29T11:33:40Z"
|
||||||
mac: ENC[AES256_GCM,data:+m1nB00cOyr5IuxUMwvumkEIPKbYSw30UQVFyLPe+4VSOniKm8zxb52DTTaZyFyxUNAQOWewdZfvPRpTHBAgqt/HE8dMcALqPLAneNzpxIYr7oUh3TkRw9Qkk8NVsZnqTiar6C0n9xsA23tfVt5FSByqYzuAirNdwbKi2pkH59c=,iv:jonNGWQP0plAL/lrpiBKwpVcXULWl2+ZOnzBI47J1Ss=,tag:DjBN2/V5SUYq0zeYkdJylQ==,type:str]
|
mac: ENC[AES256_GCM,data:hXns2uTM8+bX9J1EWxKcA7v1oYayBMfyB57pMgayEQHV8PvdIWi7rYez+4OCuMBZzBNVAZWn2AfzYxC2VJJnKrajrvwGZ/neJ1qvd2sfY9e9TskZv+c6nqnRuXl0OGi+kP+6EVSmZPiSFUODZd+xtAku8dIMGO8LZeZoGYjCBfs=,iv:EFZZpw/QKiuEtShBhB2bP6qq69T6jlrJk81FpqSt5m8=,tag:VCqctIBaFK2DQqoj1sxv7g==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
version: 3.13.3
|
version: 3.13.3
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:aqlMnoVkGtH9z3fJRweeC0OYf7LGqJU2sWA9Q25dKK6NuNyJd4BvjPtpfeg/WhVtJsaOtcbwVm4WAhVK9FARE8g8j+vmq0f6BAU4s6mx0ZIhl+mP+/hIpt//LOdd+9YezelJxdpzUyZbdAngU99rsTluLRe2XmZ7Fquxd8yH/OHenSDY6dizp9+5jfEi8EU+EmuXvuWMPY59xnlnqYNPfSFxs43/pS402LzJoJ5H+cBPprddkUBVzy4cBQvMnrRFUSjnqp74ovZkfIWFqDWQ5YgSU2PjatBg18oulZ7wNRhQ6OLqj6gsu+xrMjNFwnp7rMlA3X//hIidxTkVcYITycXd8KzuMIaofUpnwoyT34fy6+H35/39iiEyG4LRTrOOKRDzXkY2rhJUxFSZ8GlhNhMd0RlkmLngVYrtjsswJ9meIwoAFLPYt7BC61PJf0TXdtk=,iv:mCA819J9LpAOj8QxFAkrHI9wFJIy8qVxv31D6IwWFnk=,tag:qqMtM0eyHbQEyl6ND/wf7g==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOZVpRYmNMQ1E4ZlFXMDVq\nbW1FcnYzWTBHQmdhSjlsZUtZK3dzNUZjY1FvCkV4VXU4MTcwNVZoZUpVVS9JeHE5\nbGk3UzE3YmpZQ1JISHV0RHJqSG9ZNXcKLS0tIHhBdnIvMjJkeWlVRE9Fb0FhWjNX\nUjBVWDltK2w0akkwOTJaTGNSWWNrRXMKqZRNnHiXvn1QBoSGdABp7vOqNlsEN6Xr\nDp3NByXow6PuRuWvQXHzd+WC+ADkwNaaiT6TUrbZcd/Pl8Ges9kcZg==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2VFBxc2ZMUFp0YkJhVFhW\nc0ZNbnJUcGtkT1VLR2YrVGhRRFVTZFlyZXlJCmVnblNBNGxIdHh4Q0IweWR1b0dq\nRWFyOURuWkdkRE1RTnJRMEpXdk9HaUUKLS0tIFhKV3FoWW1zRk1pMlhuWWlhV2E5\nQzJTRHAyc0JtSjd2NHlJODZVbndaVDgKFA4565X/4FqNq/fZDZTg81/55hZi4c7b\nTti2AnyE3OcY/kurXJFHRinVMqURQf1fx9MxqUYRitiCz4qe5zFF+A==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1sweerhrga9yf8x6sv0apz4ed4g48rnlcq34rpv20t0rcelwgpgeqwvndzz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwRWlKTFNocnBRN01pZ0hm\na3pyRUkxMWM3dE1iTkZZcGRYUFcyb1RDb2h3Ckt4blpGZHBjcnZ4SVE5UnF4cmY4\nWW0rZG5wUkZINVk5a2lmSzN2L3JJY1kKLS0tIExpWGlBRFZJVGpGbFhxRE5ZYjBo\nQVliTlFIZ1U5dE9xbDhHMUtxenpBSm8KY6sIFEfK8p+70IXsC4Jwb9Lm/pd9+V6K\n4JAzGrpA6mAuIwwSNnbdcA5j8FmBhCpK6nLBWmFhGm9Y+MRTaM7Jrw==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1nruncs4l0ufk7yuc4des8p99c0alfndl0lhsws8tycl5pplfp56s30af5f"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoV0pIUXlyOFFTUHVTdllu\neG10VENOYWl5Wk0ycnpLUytTbnlrQ25pMkFRCnhyRU5xWW9vQXJJOHdFMGZqMkR1\nSktqT3lOdVVPRUN4YTlveGp0NXpqd0kKLS0tIGRaTktra0ZtSVpzSHBrY3VSSUph\nRVRZOSsxTTNmMmltMlJnVy9oT2VEWU0Krxf49B1BsrWn05fqg+cZ0k0PtfJJNfn0\nUL44RUWXWbK2igQHaIct9DfYe7DEonBJeROuxDYm8g7yNOv15S+P4Q==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1529taqdwr6t0w7cvzmty0d5y5593wffl0krt48j6uc4u39k56g2qf6ywtp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2026-07-28T01:44:26Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:eyOSn03dzHSgkshPzLVwc95382eEFaDQarHs9l83dtcsb1Ui9CjkKipl2DVSUb6bdMUH1qKYmXqJhwFnAZbFZFjT4VTKtutNtM+OkhVXfT+fJs+1+u7k+ZUYFL9HuxKA6AWpwX3eJ8vmJDJZaAayJbm4PRzOyJywvKeneQqdUS0=,iv:QRKltR4qzVofo/Elt2Us/lrHlD7BenX605X31x+Ng78=,tag:SZ97CcfgMJeu3yqNk8Y/cA==,type:str]",
|
|
||||||
"version": "3.13.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+32
-28
@@ -7,13 +7,14 @@
|
|||||||
lanGateway = "192.168.2.254"; # LAN default gateway (router)
|
lanGateway = "192.168.2.254"; # LAN default gateway (router)
|
||||||
lanPrefixLength = 24; # LAN subnet prefix length (/24 = 255.255.255.0)
|
lanPrefixLength = 24; # LAN subnet prefix length (/24 = 255.255.255.0)
|
||||||
lxcLanInterface = "eth0"; # LAN NIC name in LXC containers (set by Proxmox --net0 name=eth0)
|
lxcLanInterface = "eth0"; # LAN NIC name in LXC containers (set by Proxmox --net0 name=eth0)
|
||||||
|
lxcStorageInterface = "eth1"; # storage-client NIC name in LXC containers (vmbr2, --net1)
|
||||||
vmLanInterface = "ens18"; # LAN NIC name in Proxmox VMs (virtio, first NIC)
|
vmLanInterface = "ens18"; # LAN NIC name in Proxmox VMs (virtio, first NIC)
|
||||||
vmStorageInterface = "ens19"; # storage NIC name in HA server VMs (virtio, second NIC on vmbr1)
|
vmStorageInterface = "ens19"; # cluster-internal NIC in HA VMs (vmbr1 — DRBD + Corosync only)
|
||||||
|
vmStorageClientInterface = "ens20"; # storage-client NIC in HA VMs (vmbr2 — iSCSI/NFS VIP)
|
||||||
pxeServerIp = "192.168.2.223"; # pxe-boot LXC container LAN IP
|
pxeServerIp = "192.168.2.223"; # pxe-boot LXC container LAN IP
|
||||||
nixCacheIp = "192.168.2.224"; # nix-cache LXC container LAN IP
|
nixCacheIp = "192.168.2.224"; # nix-cache LXC container LAN IP
|
||||||
tailscaleRouterIp = "192.168.2.222"; # tailscale-router LXC container LAN IP
|
tailscaleRouterIp = "192.168.2.222"; # tailscale-router LXC container LAN IP
|
||||||
torRelayIp = "192.168.2.221"; # tor-relay LXC container LAN IP
|
torRelayIp = "192.168.2.221"; # tor-relay LXC container LAN IP
|
||||||
serverIp = "192.168.2.226"; # server (NFS/ZFS) Proxmox VM LAN IP
|
|
||||||
dockerIp = "192.168.2.225"; # docker Proxmox VM LAN IP
|
dockerIp = "192.168.2.225"; # docker Proxmox VM LAN IP
|
||||||
pbsIp = "192.168.2.244"; # Proxmox Backup Server LAN IP (not NixOS-managed)
|
pbsIp = "192.168.2.244"; # Proxmox Backup Server LAN IP (not NixOS-managed)
|
||||||
domainControllerIp = "192.168.2.253"; # FreeIPA domain controller — authoritative DNS for sweet.home (not NixOS-managed)
|
domainControllerIp = "192.168.2.253"; # FreeIPA domain controller — authoritative DNS for sweet.home (not NixOS-managed)
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
|
|
||||||
# Cross-host references (LAN hostnames/users other hosts reach over the network)
|
# Cross-host references (LAN hostnames/users other hosts reach over the network)
|
||||||
nixCacheHost = "nix-cache"; # substituter/remote-builder hostname
|
nixCacheHost = "nix-cache"; # substituter/remote-builder hostname
|
||||||
nfsServerHost = "server"; # NFS export source hostname
|
|
||||||
dockerHost = "docker"; # docker-compose stack host
|
dockerHost = "docker"; # docker-compose stack host
|
||||||
|
|
||||||
# Raspberry Pi's own Tailscale hostname (not fronted by `server` — it
|
# Raspberry Pi's own Tailscale hostname (not fronted by `server` — it
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBZ9WKKAlP9Z7GQdgaZ1Xgw9C+vja2lqEZO5rJFpVqYN root@ha-server-1"
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBZ9WKKAlP9Z7GQdgaZ1Xgw9C+vja2lqEZO5rJFpVqYN root@ha-server-1"
|
||||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGNKlaaMckd8nLWNGz4B2QokXjnnIvM+rEUv+R6h0sp root@ha-server-2"
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGNKlaaMckd8nLWNGz4B2QokXjnnIvM+rEUv+R6h0sp root@ha-server-2"
|
||||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK+XeMco7OxUpjrjZm54HogMs9QB5xlcKmElASRvrmlW root@nixos"
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK+XeMco7OxUpjrjZm54HogMs9QB5xlcKmElASRvrmlW root@nixos"
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMseQwPpmaa6cgV5U8KhUsiVSYARG85zGa9rho0LJWks wayne@pve1"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Admin SSH public key, authorized on the primary user of every host and
|
# Admin SSH public key, authorized on the primary user of every host and
|
||||||
@@ -99,21 +100,32 @@
|
|||||||
dockerAccessGid = 50010;
|
dockerAccessGid = 50010;
|
||||||
|
|
||||||
# HA file server cluster
|
# HA file server cluster
|
||||||
# LAN IPs (vmbr0 / ens18) — client-facing: iSCSI initiators, NFS, management.
|
# LAN IPs (vmbr0 / ens18) — management only after storage migration.
|
||||||
# Storage IPs (vmbr1 / ens19) — isolated internal bridge, used for DRBD
|
# Cluster IPs (vmbr1 / ens19) — VLAN 10 (192.168.10.x), isolated internal bridge,
|
||||||
# replication and Corosync heartbeat only; never leaves pve1.
|
# DRBD replication and Corosync heartbeat only; never leaves pve1.
|
||||||
# haServerVip: floating virtual IP managed by Pacemaker's IPaddr2 resource;
|
# Storage-client IPs (vmbr2 / ens20) — VLAN 20 (192.168.20.x), isolated internal
|
||||||
# NFS and iSCSI clients connect here regardless of which node is Active.
|
# bridge for iSCSI; docker and server VMs connect here instead of crossing vmbr0.
|
||||||
|
# haServerVip: floating virtual IP on vmbr2, managed by Pacemaker IPaddr2;
|
||||||
|
# iSCSI clients connect here regardless of which node is Active.
|
||||||
|
# Protocol separation: iSCSI on storage-client subnet (VLAN 20) only;
|
||||||
|
# NFS on LAN subnet (VLAN 2) only. Enforced by firewall on the HA nodes.
|
||||||
haServer1Host = "ha-server-1";
|
haServer1Host = "ha-server-1";
|
||||||
haServer2Host = "ha-server-2";
|
haServer2Host = "ha-server-2";
|
||||||
haServer1Ip = "192.168.2.228"; # LAN IP, node 1
|
haServer1Ip = "192.168.2.228"; # LAN IP, node 1 (vmbr0 / ens18)
|
||||||
haServer2Ip = "192.168.2.227"; # LAN IP, node 2
|
haServer2Ip = "192.168.2.227"; # LAN IP, node 2 (vmbr0 / ens18)
|
||||||
haServerVip = "192.168.2.229"; # floating VIP (Pacemaker IPaddr2)
|
haServer1StorageIp = "192.168.10.228"; # cluster-net IP, node 1 (vmbr1 / ens19, VLAN 10)
|
||||||
haServer1StorageIp = "192.168.4.228"; # storage-net IP, node 1 (vmbr1 / ens19)
|
haServer2StorageIp = "192.168.10.227"; # cluster-net IP, node 2 (vmbr1 / ens19, VLAN 10)
|
||||||
haServer2StorageIp = "192.168.4.227"; # storage-net IP, node 2 (vmbr1 / ens19)
|
haStorageCidr = "192.168.10.224/29"; # cluster subnet — VLAN 10, internal to pve1 only
|
||||||
haStorageCidr = "192.168.4.0/29"; # storage subnet — internal to pve1 only
|
haStoragePrefixLength = 29; # cluster subnet prefix length (/29)
|
||||||
haStoragePrefixLength = 29; # storage subnet prefix length (/29)
|
haServer1ClientIp = "192.168.20.228"; # storage-client IP, node 1 (vmbr2 / ens20, VLAN 20)
|
||||||
|
haServer2ClientIp = "192.168.20.227"; # storage-client IP, node 2 (vmbr2 / ens20, VLAN 20)
|
||||||
|
haServerVip = "192.168.20.229"; # storage-client floating VIP on vmbr2 (Pacemaker IPaddr2 vip-storage, VLAN 20)
|
||||||
|
haServerLanVip = "192.168.2.229"; # LAN floating VIP on vmbr0 (Pacemaker IPaddr2 vip-lan) — NFS access
|
||||||
|
dockerStorageIp = "192.168.20.225"; # docker CT storage-client IP (vmbr2 / eth1, VLAN 20)
|
||||||
|
haClientCidr = "192.168.20.0/24"; # storage-client subnet — VLAN 20, internal to pve1 only
|
||||||
|
haClientPrefixLength = 24; # storage-client subnet prefix length (/24)
|
||||||
haStorageRoot = "/srv/ha-data"; # XFS-over-DRBD mount point on the Active node
|
haStorageRoot = "/srv/ha-data"; # XFS-over-DRBD mount point on the Active node
|
||||||
|
haStorageNfsFqdn = "nfs.storage.home"; # NFS VIP FQDN (storage.home zone) — resolves to haServerVip; use this in fileSystems device strings
|
||||||
haIscsiIqn = "iqn.2026-01.home.sweet:ha-storage";
|
haIscsiIqn = "iqn.2026-01.home.sweet:ha-storage";
|
||||||
# DRBD backing disk — identified by SCSI controller path so it resolves to the
|
# DRBD backing disk — identified by SCSI controller path so it resolves to the
|
||||||
# correct block device regardless of OS-level naming (sda vs sdb can differ
|
# correct block device regardless of OS-level naming (sda vs sdb can differ
|
||||||
@@ -122,19 +134,11 @@
|
|||||||
haServerDrbdDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1";
|
haServerDrbdDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1";
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
storageRoot = "/tank"; # ZFS pool root on `server`
|
# NFS share definitions — used by ha-server.nix (exports), docker/mount-data.nix,
|
||||||
|
# and pxe-boot/mount-pxe-images.nix (mounts). `subpath` is relative to
|
||||||
# NFS datasets exported from `storageRoot` on `nfsServerHost` and mounted
|
# haStorageRoot; `mountpoint` is the absolute local path on each client.
|
||||||
# by client hosts. `subpath` is relative to `storageRoot` — combined with
|
# Renaming a share only needs changing it here — exports and all client
|
||||||
# it to build both the export line in modules/build-types/server.nix and
|
# mounts follow automatically.
|
||||||
# the "<nfsServerHost>:<storageRoot>/<subpath>" device string each client
|
|
||||||
# mount uses in modules/docker/mount-data.nix. `mountpoint` is the
|
|
||||||
# absolute local path clients mount it at, referenced by that same file's
|
|
||||||
# fileSystems attribute name plus every other place that needs to know
|
|
||||||
# where the share lives locally (modules/build-types/docker.nix's
|
|
||||||
# tmpfiles rules, modules/traefik/rotate-logs.nix's log path). Renaming a
|
|
||||||
# dataset or moving where it's mounted only needs changing it here — the
|
|
||||||
# export and every client reference follow automatically.
|
|
||||||
nfsShares = {
|
nfsShares = {
|
||||||
options = "(rw,sync,no_subtree_check,no_root_squash)";
|
options = "(rw,sync,no_subtree_check,no_root_squash)";
|
||||||
dockerConfig = {
|
dockerConfig = {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPW/X9Mdrqs0wLR7XbEDTihk7TEkNZ3LcCeXoa2ITSDA linode-server
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:hzI2cJ81+Psv35MMZO06UHpC8F9FLVkfUzn0ReuR+WoUds8D5zhDFbQHeR6ByeHTuK8Hlj712okhoXh6Vm7l3WmhLJZkr8IGLsV9W+P2PYityjuOtBPrphrKUeSqxJfjQ31/EhZLOqw/508XUmQDNcs1/n2g0TtQ2UQSTAuO8r1OlRfRSPizvVdj7lu+Vqg3dDBRETSOJAYIh8XQXoQWl3M4dS7jOYUvgc4EsYOWrvPPPS+8xXzctBPeToasY0IyWtLzoeNajCs5EpH5u7S5K9S40/vFPa45Ic2IxEkb1HHClyN6lXWbnc5QlJ5HPrq+2YBU2y9dJHd9DQiwEbcEBIvR3/Lo9puVDppXx9kez7i4XR1UHU7WVu39nQZuP50BQQazj+eM0/HJapXfjoqtaaq0qAGN4onnmRd0vEmrZZl4WMXmx8m9HhxdNJoZ4C2Wb2skdHZok88tPzjd4r7xKzVhAbB+wqS4LML6FmCNk+m0USQXa25USXs0JDV7lgiTFQJPE/67JGa820WFicGs,iv:YmOeK2Ha3yBXumVO9strgLgqNmPOcnqwUDJv5QNR1WM=,tag:Z7kZvQLDOKx3uX4dRXJqeQ==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOSWl5ajRSVndlMHJJQTFp\nYzRRU3RQMEVzazB0Sm5FdWZmMzBRaUl5RUFvCjgwRUZWdUpvUjkzWHFVdThIZ1ZV\nM2RMNy9pUWF4VVBCVHBGWHgwakhKQ2cKLS0tIERIOTVVZm55QWkvL25SRGZkZmtI\nT2o3ZEJNQ2hmTDNoRUx2Z3UxcmlyekUKLOajmvRfLdCJL74PKSgBtIXDuAVd8NwM\nh4BtDs0hONOz82JaBqFw8Uz28hVFG/gcS80br1o2klqPd2gN62PCVw==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2026-07-25T11:45:33Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:JJC+12gTCsDVvMWRtL5cj50kf1n6Xn2j1hNBnwvZXUA9Pdd96SXt61U/Q80h8GZ7Ycs/slsV7h5f3g6+8tV0PcdBM/vy0vPM5qX1zMySyMv2p+dkJb9MwQpPg2xAQ9jjYM9237p5n9nysgu74h4V7ccBqmBzp764bL9wx6hEzS4=,iv:U6u1GFvoxaqxmHv1zCht24nW0ZMJR2b4pZqCG3bGNzs=,tag:MocwywOBg/1rUuoSADuJVA==,type:str]",
|
|
||||||
"version": "3.13.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILVRdEddGd+AMNe4kXbmA9UXK8JRsPEuxPx1vhNT9ZG8 lxc-server
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:JTmbN++hNMddBdMKIdzqZ6hLD1So9hyAzwbce/QvqWBsa1At85cMRUx+P1Yi4Sq2aXCg1Tf2g8XC9DXsVfMusUGEHrHbJApwkia/xHNMcFOohscUkPltXnm/lRilm4hwJUv6ay6UyLvIqMQAzzC8YWebHkKo00rxRxQWQ77wjmUG4dJeujlK5wMH0zTXUkrf7tIdJ20Zzg1BrAU6s4FYLvRvcUSW2ROu+sF2Sxgy1qVNJfDrRnY4REh9013tTSB5IlgJzUSQLs1vHr7EpIfmA5MCCSkZjplGCKvxkcNHB3aaOkrxkGa18JeWoklRen31UPU9zMUhTjAZ6VbbIagxzaFGKVP27cOiHQPNpEIMXwaRzoprjBX2PJ/Bs6pxe6hBpmiKOb40XdtxFQx2rofPcbQwklTpM5SFwvK+/bgawy1m8O3NHMhcw1qvd+6KjUdqy++/ivFC2Nyy+VfUDyGu0JLK3X+YqU6JXXMcUWOq79pVQIL3q1ofbhVHQ8p17+V3wQQUB3kPo4EEcKikI7Z9,iv:TVYVDOiTsgXaIcuJdnd3djPWXCMDDGjpefAW0MR+7Es=,tag:jIYO1oatbvInp77d4fC8mQ==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5QlNzNEFqSkhKTVhWVisr\nS21zdVBZSnpITDdrb243c0pUdDR5WXl1bFVrCmkxaWR6U01sLzhLL0MwRGh1VG5D\nNEFiU1FtNXRBdGtPellFTXYraitCUTgKLS0tIFdwWGFnMWFCN1ZvK2VBMWR2VTJZ\nRlQ4dEpmczA4bEdvUkNhaC93OXhDTkEK0QBkaLV1mbTMlSnjmN4x+qljGipHM/DQ\nUlmBYyi3nEOrI36I/Mm8yoBZai/qWqdg6IG+sxDE49ZOLp9PhpAWww==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2026-07-25T11:45:44Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:/l6pa3LE7+kFYqH2pv2RIcYBycLrZfpb92Al3SIU2tMeFUUvh+C8q8P9CgmAaiQuQ8S2dfYIJVx67zc7cRqI/UL/dFvDSv0YaMTGQ8Wn4fIXSh01EL0f/QVaIfb+uuvyEsdjy2ScWTWcCf2ICnC/zaMmp+xP+MmR1DRBM8KPIkY=,iv:xsmaYZ6dyHLU3BVfT3jxbfWWeBvYKMT+D9MtRxF9jlo=,tag:VzOiutHQJGHqb5UCI+cxSA==,type:str]",
|
|
||||||
"version": "3.13.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGQHSubxjvaIV9Xp5ABJSKsajCZBGyGmjsdaA2TWCFP8 proxmox-server
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:GdVHSekFtRtppf8xxMPsDE0cu8Hwq3ol0iJTfrwKwaAB0IW8YS8TFAeRnfOI5AE3P1JUaGDZ1Od4UXu4Jk2+uYCgwz074qc1WukzUsd8Q3G03ojdrTA13uH8YCUTRsm3bQfB+kmH5qefn9j88UJq7TYiTLe3U0DUU/B83lgyiFIygP8CKWDgFH3jWZOWPmyVa4Z1fMWVsiNhjO4hHduamN/I2FOY7U2TI5+zcYgGeunjnniLHcQdiO0iK9TOP5SckOVPZOy6daKzGcckEq03NV4y+miSucEUHlcQqMUyvsJwETIaCq28xR2MDTyDz42SL2HIodb7FyREtxd91W6gPN1j51yUZxt4WA9R8/WczDcUgmROK+HInPp+ktjWqv2ymXXV5xidxh7m+vrGTPC+YQqyFD4eIp3+h2S/vyzHimBa2rdEp9NNXTQlH3CfL2aLC95FOme+uKTDqWA2PHfB0rQ5uhHAuAgaX5vb/GTiQii5dq4k2O4MDf9u+fdPuOSCaGdlirWmSE45SvaXLJ3M2zQq7M/UTE+wSFyc,iv:EaIsYnGxf42LWQ3hzBU/HsBda/FHInhjZkwsPLcZMEY=,tag:16OuAs3Ram+xd4RfWEBXkw==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBETE4vOHhldGRFRjdYaXpi\nSmgzTFlqL3Qzak1CelRBR29jZzR1V0pOQWw0CkhSWDkvbkJWaDVHMzlKNDZUdzhH\nWGoyWXQ1KzJZQU1kQzFvaDk2V2lWQmsKLS0tIHlTY2tua2F1TXBhamsvcDZ0bmI1\nZVFwUGtQWW1HNTM3UUs4bHRZU0xKRDgKBG2iI9JP0lhU5VCWXrpN1b2rYEYk8sOZ\n9FUO14KKMg9QRfSa2iHOa84DByx2hxVRc9wLukBUpkKOAjGSxeGMeg==\n-----END AGE ENCRYPTED FILE-----\n",
|
|
||||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2026-07-25T11:45:58Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:jG7ohweKKndoadidquejYG/1w9iL+9Xb5/IsU/C9fn/Tq5RXEjrxO37COY8sAD7dvQf7iBNsly7upsjtHaMK8ybjQDaa6IQhoGBlfSOA2O968klaJZRQRiLPzCRet252KXzOtvDscrBvyYItvyqjnW5qBbw7lfT4y2J4OA2ieC0=,iv:mMo7Y6XEjQcchNOY6eaw5LOmjFKaQKx84o9bnGNXCKI=,tag:aHkPjs7uVfv8h0REHN8YmA==,type:str]",
|
|
||||||
"version": "3.13.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user