Archived
Compare commits
324
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2741451642 | ||
|
|
9f1fed26c2 | ||
|
|
3b6ac30946 | ||
|
|
cee33aa2d8 | ||
|
|
ac9099a1f2 | ||
|
|
c96752e5d0 | ||
|
|
43314b6aa3 | ||
|
|
db84a0bcc1 | ||
|
|
419b0f706b | ||
|
|
89e1c5dc9a | ||
|
|
77db51a88f | ||
|
|
ad0b4a14e6 | ||
|
|
c9dbb03e31 | ||
|
|
b6ed0c6007 | ||
|
|
78fdf3d94c | ||
|
|
f85c65870f | ||
|
|
97c9353fa8 | ||
|
|
cded77919d | ||
|
|
71381ad990 | ||
|
|
857c81f64a | ||
|
|
334ffbda09 | ||
|
|
620a344d78 | ||
|
|
24e5c9fe0c | ||
|
|
4ed2db906a | ||
|
|
944af9597d | ||
|
|
4f0b07031d | ||
|
|
8c86144694 | ||
|
|
3c28d48bc7 | ||
|
|
e9a4913069 | ||
|
|
bf4836efac | ||
|
|
5eea38d3ca | ||
|
|
6f38ad67e0 | ||
|
|
8d43b7039c | ||
|
|
8e58226d2b | ||
|
|
a5fb0404e2 | ||
|
|
4ba9b141fe | ||
|
|
f3c2965f78 | ||
|
|
b9d3b51028 | ||
|
|
539bdf9833 | ||
|
|
a5308a7ee5 | ||
|
|
dc6c37ed2e | ||
|
|
b5e61d62bd | ||
|
|
ced1657407 | ||
|
|
7b4794211d | ||
|
|
fdf41c659c | ||
|
|
0ef8259225 | ||
|
|
629c1457a9 | ||
|
|
7ba603c005 | ||
|
|
decf3ddff9 | ||
|
|
4490dfab7d | ||
|
|
fa163b613d | ||
|
|
15bc5bd369 | ||
|
|
5a4fbbf7ad | ||
|
|
e720bbb018 | ||
|
|
40cdf724b4 | ||
|
|
aaa193645b | ||
|
|
f22ff7db79 | ||
|
|
0da3c3070f | ||
|
|
e1551feefd | ||
|
|
b4bc30cb2c | ||
|
|
5c8d55bd78 | ||
|
|
999c9a3151 | ||
|
|
1e0fff1b26 | ||
|
|
24c6469f10 | ||
|
|
c9458ac8a6 | ||
|
|
99ba0ed52b | ||
|
|
1e4072029e | ||
|
|
46649fc7e0 | ||
|
|
4cbcc3beb9 | ||
|
|
35f696ccd5 | ||
|
|
e18b605706 | ||
|
|
ee2451d87c | ||
|
|
71a6c4738c | ||
|
|
793a2b5924 | ||
|
|
98b5429fb9 | ||
|
|
f658bdbabc | ||
|
|
d7e63cd80e | ||
|
|
b88ea49880 | ||
|
|
c7268ade8e | ||
|
|
367158548e | ||
|
|
585126beee | ||
|
|
ceb491b18d | ||
|
|
6d16eaff89 | ||
|
|
e085d4707c | ||
|
|
c8d4440787 | ||
|
|
e7531b276e | ||
|
|
49a10d7cc5 | ||
|
|
ead4f55805 | ||
|
|
cac5ec45cc | ||
|
|
ec90753a09 | ||
|
|
df1ddee735 | ||
|
|
8e8261be31 | ||
|
|
5e9541741f | ||
|
|
07543d7d56 | ||
|
|
a7c4a24fc3 | ||
|
|
a98955a8da | ||
|
|
7d60741e73 | ||
|
|
427be2b287 | ||
|
|
684351b89b | ||
|
|
327ff0b44d | ||
|
|
43b4cc6aa6 | ||
|
|
dd984019a1 | ||
|
|
cb8a51b2fd | ||
|
|
18ab0ff254 | ||
|
|
5c5f22f84a | ||
|
|
094eaa752b | ||
|
|
fe9fc7364b | ||
|
|
1ce4589830 | ||
|
|
7417cc1b0a | ||
|
|
ec44b7955b | ||
|
|
cf3d8ea9a5 | ||
|
|
756e45743c | ||
|
|
7e8755d141 | ||
|
|
8194707478 | ||
|
|
d740064a35 | ||
|
|
164d14eb87 | ||
|
|
720399b00d | ||
|
|
da4d808c6a | ||
|
|
79cde50e27 | ||
|
|
d31d9fa584 | ||
|
|
c76698efb7 | ||
|
|
601db79689 | ||
|
|
acebbdbe26 | ||
|
|
fc8f7baf3e | ||
|
|
3f9b968a41 | ||
|
|
1263c7540c | ||
|
|
a8d8b1465c | ||
|
|
b76d54e702 | ||
|
|
59854a0229 | ||
|
|
dc83333526 | ||
|
|
a960c662f0 | ||
|
|
6f602b2245 | ||
|
|
e62e9c9a6a | ||
|
|
5beed2d75c | ||
|
|
fe7fc55c04 | ||
|
|
6cdae391f4 | ||
|
|
95eb494370 | ||
|
|
9294d2fd25 | ||
|
|
0fe7ddf6e8 | ||
|
|
5c2d61d35a | ||
|
|
186e9187ce | ||
|
|
e6f15404f5 | ||
|
|
44a0acc18f | ||
|
|
1fc6e63178 | ||
|
|
3589fc31d7 | ||
|
|
89746718a9 | ||
|
|
232d0e7c40 | ||
|
|
0b60d3ff2d | ||
|
|
ff82e8885d | ||
|
|
9a7411d1dd | ||
|
|
2f829ba3e7 | ||
|
|
dedd69dc42 | ||
|
|
f7f670ca4c | ||
|
|
55ba283c82 | ||
|
|
2d46d25a67 | ||
|
|
f5d29be041 | ||
|
|
e10a1572c3 | ||
|
|
578ef70aa9 | ||
|
|
e9fcbbbbcb | ||
|
|
f46ae18672 | ||
|
|
fc277294f3 | ||
|
|
f3e5ea67a0 | ||
|
|
7a8aebf679 | ||
|
|
a8d95aad02 | ||
|
|
dac5fbd574 | ||
|
|
da0c651c60 | ||
|
|
97019205da | ||
|
|
5487490b8e | ||
|
|
543ea432f0 | ||
|
|
c7bbf88dce | ||
|
|
d57145b31e | ||
|
|
1cbe80c0ca | ||
|
|
01ee261cf8 | ||
|
|
f6f30c675f | ||
|
|
60b80cbd96 | ||
|
|
27a8c7fad9 | ||
|
|
d9cee0a674 | ||
|
|
6c1891812e | ||
|
|
59316c982e | ||
|
|
9eca3bd719 | ||
|
|
f2f0fcf756 | ||
|
|
b4fb9c25f2 | ||
|
|
8955840f0a | ||
|
|
a4b49c9909 | ||
|
|
c0936a10e7 | ||
|
|
f4bbd6331d | ||
|
|
b99ba87cf6 | ||
|
|
2128353f9f | ||
|
|
7b4ce0ab3d | ||
|
|
3123565011 | ||
|
|
36f5ebdf86 | ||
|
|
bba054db85 | ||
|
|
b63a529a1d | ||
|
|
ee96713a50 | ||
|
|
1ece0c75d9 | ||
|
|
548c6f5041 | ||
|
|
bae4c8171f | ||
|
|
3748c86049 | ||
|
|
9dd969cf4c | ||
|
|
7e4b2d33fb | ||
|
|
288d50fd33 | ||
|
|
f0e76f8aff | ||
|
|
e80d195284 | ||
|
|
c770feebc9 | ||
|
|
7fd6d558d5 | ||
|
|
58c40292e2 | ||
|
|
94842875d0 | ||
|
|
cfa36b97fc | ||
|
|
4444398cac | ||
|
|
f80378f92f | ||
|
|
cd4997f429 | ||
|
|
6ce4784376 | ||
|
|
5856d45575 | ||
|
|
e3498b1087 | ||
|
|
724d9a45af | ||
|
|
109429c7da | ||
|
|
40856b2e5e | ||
|
|
23634134f0 | ||
|
|
34c55f27ca | ||
|
|
dc36a47ac9 | ||
|
|
750121e9dd | ||
|
|
479444d26a | ||
|
|
8c19ee9d72 | ||
|
|
2514d3bc89 | ||
|
|
48d6d6f7a2 | ||
|
|
cf0d62696f | ||
|
|
b2a3d5fbdd | ||
|
|
86e55ff954 | ||
|
|
d3d6382360 | ||
|
|
b8d21d78d9 | ||
|
|
dcce023b14 | ||
|
|
4c5ade5605 | ||
|
|
b232daf5e1 | ||
|
|
b65736c0dc | ||
|
|
4f54a1f0cd | ||
|
|
2d85ecec8f | ||
|
|
34bb14d9f6 | ||
|
|
515da66db9 | ||
|
|
2e9d3da301 | ||
|
|
321048626e | ||
|
|
16d6baea5f | ||
|
|
d082c6a084 | ||
|
|
ee93322ae2 | ||
|
|
d1ce8d3e71 | ||
|
|
f7c32aff12 | ||
|
|
bf88a6ebb0 | ||
|
|
de508141a8 | ||
|
|
18cd9c2342 | ||
|
|
997918e2f7 | ||
|
|
9872b8ff1d | ||
|
|
e9b225d2d6 | ||
|
|
2860f750b4 | ||
|
|
781b1d324e | ||
|
|
3014a45936 | ||
|
|
cda2132d6a | ||
|
|
6c1cc821a0 | ||
|
|
4be064572d | ||
|
|
89d506180d | ||
|
|
6e1e992652 | ||
|
|
5467c2e140 | ||
|
|
123cd2b3d7 | ||
|
|
006dd8097a | ||
|
|
18cd6e884e | ||
|
|
3102d66337 | ||
|
|
dfa5452af5 | ||
|
|
852ba2240f | ||
|
|
096dff4fa0 | ||
|
|
b5f749daa9 | ||
|
|
adaf53d647 | ||
|
|
01679f1639 | ||
|
|
8f4c88347d | ||
|
|
e9832d87c4 | ||
|
|
08aac4261f | ||
|
|
2526b2dca7 | ||
|
|
2df53fd5d7 | ||
|
|
5e2ff76cf7 | ||
|
|
e8c4122460 | ||
|
|
5db41b1166 | ||
|
|
ea7794dc05 | ||
|
|
67752fb1e8 | ||
|
|
1a14b1d4d3 | ||
|
|
68aea4cdcc | ||
|
|
0853952269 | ||
|
|
055577ee91 | ||
|
|
a63e1c70c3 | ||
|
|
a9745b594b | ||
|
|
78bb784265 | ||
|
|
784e064fa2 | ||
|
|
4a5afa6c1c | ||
|
|
c844ccc4e3 | ||
|
|
9a0aea8f89 | ||
|
|
b013e28dcd | ||
|
|
5a56030f6e | ||
|
|
f8719437ba | ||
|
|
9e34b9cbb9 | ||
|
|
4dabd725f0 | ||
|
|
2743d664a5 | ||
|
|
cfa34f3565 | ||
|
|
e021b49412 | ||
|
|
0c29c6a93d | ||
|
|
c329988cdd | ||
|
|
7a2b5ecf71 | ||
|
|
d74efd9f66 | ||
|
|
63a8c627f5 | ||
|
|
e4b335be23 | ||
|
|
0bf99c56cc | ||
|
|
e368f68ad7 | ||
|
|
fa52c2849a | ||
|
|
dce3788499 | ||
|
|
e00be5d2da | ||
|
|
82eea7f088 | ||
|
|
955a443b36 | ||
|
|
4800aebf43 | ||
|
|
cb737642e5 | ||
|
|
6d5670c8d2 | ||
|
|
c911a605e9 | ||
|
|
6002c5c738 | ||
|
|
92c50df2f1 | ||
|
|
45e61844d5 | ||
|
|
e72df8fed5 | ||
|
|
5497a5b0ae | ||
|
|
e10e493ddd | ||
|
|
289163c712 | ||
|
|
cbf1239be4 |
@@ -23,3 +23,5 @@ host-keys/
|
||||
# Temporary Milestone 1 audit checklist (remove-sensetive-info-refactor.md)
|
||||
# - working notes only, never committed, deleted once every row is rotated.
|
||||
secrets-inventory.md
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
+179
-30
@@ -1,18 +1,28 @@
|
||||
keys:
|
||||
- &admin age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad
|
||||
- &docker age19gfn2yedg76dmztm4hncr7vf3r3c9j0qpt4rap7y7gersjk4m3ks2lhd0e
|
||||
- &server age1ll6hj5ggruetgjwjfnplpn5xtq35uhlcdflksx3xmnjm6s3uad9sz70jkf
|
||||
- &nix-cache age120le4a5l8dh3lyfgvmj3d9ksmej6ajs5mer5y7r0vfg3x9fn69dqf8xgzu
|
||||
- &nix-minimal age120whqj96g26lsgy4udvgsn8dc9lumh8jeu3a564fx79rjr5lxffqmrljuu
|
||||
- &proxmox-minimal age10at8862478urh0eeuwh8hzln6ck78jgwtztgxatwqlzwagg77y5snm4xzg
|
||||
- &lxc-nix-cache age1xjst4frdh0th6q8m7p7u9g5af7ty5jqeum0p6z8a52a9q7st7ewqw8yl9j
|
||||
- &lxc-docker age1ezk9x53zt8kcnscdm80jcyf0xq97vndv7jsn3rl8cc0cwm2jmpmq372dzs
|
||||
- &lxc-minimal age1jy444f9d9stygj4p3w9kh54cqcfr654tvr75tdvee5cxsgtdtc9q3v60ep
|
||||
- &lxc-pxe-boot age1fxxzpnfse8nd9wz78ht3m0plrmraacf4cpga0pe8fm2tdnqcgy8q7qsyvp
|
||||
- &lxc-gui age190htw7prp4vln076dxjx3gxxaq06h0zl0te7cqgpx79vl3lhkaes8suy05
|
||||
- &proxmox-server age1ukpqxzl44mnjpy5r96sfuc5sqzm47u4k8ujjh5qdgy6jvl9uqgpspymqfk
|
||||
- &vm-server age15kh7akxlx7zn00tey79rq2g8lgs4j5y77rcnyfxrxap8ckfu0a9sqvtdhh
|
||||
- &baremetal-gui age1ehkswwz2pqaz4svzh7ela5tdnssl8kn6d4vwwxd6zwg8exfpd43syyrrjp
|
||||
- &proxmox-minimal age19m0m7vdfg86yqy8l5mmle5jdd0unrn3f55t232w8h5ey42cqw34sfpt32n
|
||||
- &lxc-gui age1rrxqea6q6pn39sw8y5te63h2py8jgjl9v0jyper86w3ggtn67upqg3ah39
|
||||
- &baremetal-gui age1adur9g330gua4l6ndk8cqjg35qc8yxwgme6wrl2hpylcc7vxm38q05ejuy
|
||||
- &linode-docker age17e89ty6p0fw24daanen57wg8uald9s025t3wwxsw269svwpmgvrshfvfvt
|
||||
- &linode-gui age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs
|
||||
- &linode-minimal age1e7l8dusgmgfzd2cxrrzwepzjxt69hzqj4epee0cs27u6yg4kxcuqm34ncx
|
||||
- &linode-nix-cache age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e
|
||||
- &linode-tailscale-router age1f7usptjx9rv4rxauasve200gxtdt9jkqhhdqstlf20wvlm7u75rsjfw50m
|
||||
- &lxc-docker age17jqc66x9yeshfgd9v78mj483r4zzarqdtuxtrkxe4x5mw679gphshd94th
|
||||
- &lxc-minimal age1px0h5l9zp2dww0m8fncrc82kfdmzplsfv2ltat7sna28xpg09pqqcl3s2k
|
||||
- &lxc-nix-cache age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7
|
||||
- &lxc-pxe-boot age16j42pdc5dr6wnj7xayhkqdj2rny9u68fcqejs50hqq42scssh4gsnrrnlt
|
||||
- &lxc-tailscale-router age1k7d2du5mejsmv5rzavm4xwgpthqvcfsehduquv28nzs53zppa3kqngfxq2
|
||||
- &lxc-tor-relay age16kqfmvz4e23hmdlqresnyw69ej604s320mmd49h4hm3fhqchtgyqrws0k2
|
||||
- &proxmox-docker age1arhf2q45zw6wf2uevju4savp575x3m2tfvved5zzq3ay92ynua9s3cm92c
|
||||
- &proxmox-gui age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn
|
||||
- &proxmox-nix-cache age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68
|
||||
- &proxmox-pxe-boot age1ug787sgt6st6k82fgkrug2lzltw4qsukrrqqs3w27ewwqj8rg4hsxcmylz
|
||||
- &proxmox-tailscale-router age1zhfyuzlq40reuqlr34gf77852nhs3t6mqfzrqmas8z6sxk7tcfhsungrm0
|
||||
- &proxmox-ha-server-1 age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6
|
||||
- &proxmox-ha-server-2 age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r
|
||||
- &proxmox-ha-docker-1 age16y0vfts5v2k20e2rj23qa5lc5gm96gm64uwsqsr0y688xl0ne46qcfr0sm
|
||||
- &proxmox-ha-docker-2 age1tm3zj5lp3elw2j832az4nj9xxmhqd66ag3cqcv3vskvmd7qdmqmsdpdcn0
|
||||
|
||||
creation_rules:
|
||||
# Shared across every currently-deployed host: root/nixos password hash,
|
||||
@@ -23,40 +33,81 @@ creation_rules:
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *docker
|
||||
- *server
|
||||
- *nix-cache
|
||||
- *lxc-minimal
|
||||
- *nix-minimal
|
||||
- *lxc-nix-cache
|
||||
- *proxmox-minimal
|
||||
- *lxc-docker
|
||||
- *lxc-pxe-boot
|
||||
- *lxc-gui
|
||||
- *proxmox-server
|
||||
- *vm-server
|
||||
- *baremetal-gui
|
||||
- *linode-docker
|
||||
- *linode-gui
|
||||
- *linode-minimal
|
||||
- *linode-nix-cache
|
||||
- *linode-tailscale-router
|
||||
- *lxc-docker
|
||||
- *lxc-minimal
|
||||
- *lxc-nix-cache
|
||||
- *lxc-pxe-boot
|
||||
- *lxc-tailscale-router
|
||||
- *lxc-tor-relay
|
||||
- *proxmox-docker
|
||||
- *proxmox-gui
|
||||
- *proxmox-nix-cache
|
||||
- *proxmox-pxe-boot
|
||||
- *proxmox-tailscale-router
|
||||
- *proxmox-ha-server-1
|
||||
- *proxmox-ha-server-2
|
||||
- *proxmox-ha-docker-1
|
||||
- *proxmox-ha-docker-2
|
||||
|
||||
- path_regex: secrets/nix-cache\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *nix-cache
|
||||
- *linode-nix-cache
|
||||
- *lxc-nix-cache
|
||||
- *proxmox-nix-cache
|
||||
|
||||
- path_regex: secrets/server\.yaml$
|
||||
- path_regex: secrets/tor-relay\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *server
|
||||
- *proxmox-server
|
||||
- *vm-server
|
||||
- *lxc-tor-relay
|
||||
|
||||
- path_regex: secrets/docker\.yaml$
|
||||
- path_regex: secrets/tailscale-router\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *docker
|
||||
- *linode-tailscale-router
|
||||
- *lxc-tailscale-router
|
||||
- *proxmox-tailscale-router
|
||||
|
||||
# HA file server per-node secrets (beszel-token).
|
||||
# proxmox-ha-server-1 / proxmox-ha-server-2 keys are added automatically
|
||||
# by scripts/secrets/sync-host-keys.sh once the hosts are provisioned;
|
||||
# until then only the admin key can decrypt these files.
|
||||
- path_regex: secrets/ha-server-1\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-server-1
|
||||
# proxmox-ha-server-1 added by sync-host-keys.sh
|
||||
|
||||
- path_regex: secrets/ha-server-2\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-server-2
|
||||
# proxmox-ha-server-2 added by sync-host-keys.sh
|
||||
|
||||
# Shared HA cluster corosync authkey (binary sops file).
|
||||
# Encrypted for both HA nodes so either can decrypt on boot.
|
||||
# Both host keys added by sync-host-keys.sh; admin key allows initial creation.
|
||||
- path_regex: secrets/ha-corosync-authkey$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-server-1
|
||||
- *proxmox-ha-server-2
|
||||
# proxmox-ha-server-1 added by sync-host-keys.sh
|
||||
# proxmox-ha-server-2 added by sync-host-keys.sh
|
||||
|
||||
# gui-host-specific secrets (currently: wifi-password, see
|
||||
# modules/networking/wifi.nix). Only *lxc-gui has a registered key today
|
||||
@@ -70,3 +121,101 @@ creation_rules:
|
||||
- *admin
|
||||
- *lxc-gui
|
||||
- *baremetal-gui
|
||||
- *linode-gui
|
||||
- *proxmox-gui
|
||||
|
||||
# IPA host keytabs (binary sops files).
|
||||
# Each keytab is encrypted for all platform variants of that host so any
|
||||
# deployed variant can decrypt it at boot. Run
|
||||
# scripts/ipa/create-nixos-ipa-host-account.sh <hostname> to enroll a new
|
||||
# host and produce the keytab; this section is updated by that script.
|
||||
|
||||
- path_regex: secrets/nix-cache\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *linode-nix-cache
|
||||
- *lxc-nix-cache
|
||||
- *proxmox-nix-cache
|
||||
|
||||
- path_regex: secrets/tailscale-router\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *linode-tailscale-router
|
||||
- *lxc-tailscale-router
|
||||
- *proxmox-tailscale-router
|
||||
|
||||
- path_regex: secrets/pxe-boot\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *lxc-pxe-boot
|
||||
- *proxmox-pxe-boot
|
||||
|
||||
# nixos = the workstation (hosts/nixos/host.nix). All gui platform variants
|
||||
# share the hostname "nixos" and must be able to decrypt at boot.
|
||||
- path_regex: secrets/nixos\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *baremetal-gui
|
||||
- *lxc-gui
|
||||
- *proxmox-gui
|
||||
- *linode-gui
|
||||
|
||||
- path_regex: secrets/docker\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *linode-docker
|
||||
- *lxc-docker
|
||||
- *proxmox-docker
|
||||
|
||||
- path_regex: secrets/tor-relay\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *lxc-tor-relay
|
||||
|
||||
- path_regex: secrets/nix-minimal\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *lxc-minimal
|
||||
- *proxmox-minimal
|
||||
- *linode-minimal
|
||||
|
||||
# Host keytab for ha-server-1 FreeIPA enrollment (binary sops file).
|
||||
# Generated by scripts/ipa/create-nixos-ipa-host-account.sh.
|
||||
- path_regex: secrets/ha-server-1\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-server-1
|
||||
# proxmox-ha-server-1 added by sync-host-keys.sh
|
||||
|
||||
# Host keytab for ha-server-2 FreeIPA enrollment (binary sops file).
|
||||
# Generated by scripts/ipa/create-nixos-ipa-host-account.sh.
|
||||
- path_regex: secrets/ha-server-2\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-server-2
|
||||
# proxmox-ha-server-2 added by sync-host-keys.sh
|
||||
|
||||
# Host keytab for ha-docker-1 FreeIPA enrollment (binary sops file).
|
||||
# Generated by scripts/ipa/create-nixos-ipa-host-account.sh.
|
||||
- path_regex: secrets/ha-docker-1\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-docker-1
|
||||
|
||||
# Host keytab for ha-docker-2 FreeIPA enrollment (binary sops file).
|
||||
# Generated by scripts/ipa/create-nixos-ipa-host-account.sh.
|
||||
- path_regex: secrets/ha-docker-2\.keytab$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *proxmox-ha-docker-2
|
||||
|
||||
@@ -6,12 +6,14 @@ This repository contains flake-based NixOS configurations for Wayne's LAN
|
||||
servers and workstation.
|
||||
|
||||
The flake exposes NixOS configurations named `<platform>-<buildtype>`
|
||||
(platforms: `linode`, `proxmox`, `lxc`; build types: `minimal`, `nix-cache`,
|
||||
`server`, `docker`, `gui`, `pxe-boot`, `tailscale-exit-node`, `tor-relay`), generated from `modules/platforms/*`
|
||||
and `modules/build-types/*` by the `mkTarget` function in `flake.nix`. Not
|
||||
every combination is built — `pxe-boot` has no `linode` variant. See
|
||||
`README.md` for the full current target list; treat `flake.nix` as the
|
||||
source of truth since this list can drift.
|
||||
(platforms: `linode`, `proxmox`, `lxc`, `baremetal`; build types: `minimal`,
|
||||
`nix-cache`, `docker`, `gui`, `pxe-boot`, `tailscale-router`,
|
||||
`tor-relay`, `ha-server`), generated from `modules/platforms/*` and
|
||||
`modules/build-types/*` by the `mkTarget` function in `flake.nix`. Not every
|
||||
combination is built — `pxe-boot` has no `linode` variant, `ha-server` only
|
||||
exists on `proxmox`, and `tor-relay` only exists on `lxc`. See `README.md`
|
||||
for the full current target list; treat `flake.nix` as the source of truth
|
||||
since this list can drift.
|
||||
|
||||
Do not deploy, switch, reboot, repartition, format disks, or run destructive
|
||||
install commands from this repository unless explicitly asked.
|
||||
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
# Flake End-to-End Audit Report
|
||||
|
||||
**Date:** 2026-07-21
|
||||
**Scope:** Full static lint/eval sweep + live build/deploy/interrogate/destroy testing of every `lxc-*` and `proxmox-*` flake target against `pve.sweet.home`, plus an audit of the operator's ability to manage the flake/secrets tooling.
|
||||
**Branch:** `worktree-flake-e2e-audit` (this session's isolated worktree)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The flake itself is in good shape: `nixpkgs-fmt`, `statix`, and a full eval + dry-run build of every host and package are all clean. Every `lxc-*`/`proxmox-*` target's NixOS configuration builds successfully — no target has a broken derivation graph.
|
||||
|
||||
The issues found are **operational, not code-level**:
|
||||
|
||||
1. **pve.sweet.home is critically low on disk space** (91-95% full during this session) and cannot currently build the two largest closures (`gui`, `pxe-boot`) to completion — this actively blocks deploying/redeploying those hosts via the documented workflow.
|
||||
2. **A real, reproducible secrets-decryption failure** was caught live: a stale cached container image (built before a same-day sops-key fix) boots with sshd never starting and every secret failing to decrypt. This is a **general hazard in `create-proxmox-resource.sh`'s "reuse the cached image if present" default**, not a one-off.
|
||||
3. **sops key/anchor drift**: `proxmox-minimal` has a `.sops.yaml` recipient anchor with no corresponding private key anywhere in this environment; several `lxc-*`/`proxmox-*` targets have no sops registration at all yet.
|
||||
4. One concrete script bug was found and **fixed in this session**: `create-proxmox-resource.sh` never enabled the QEMU guest agent channel on VMs it creates, despite the guest OS already running it.
|
||||
5. A management-surface audit (of the operator's ability to run this repo day to day) found 5 process gaps, detailed below.
|
||||
|
||||
Nothing here required or received a `nixos-rebuild switch/boot/test`, `nixos-install`, or any disk-formatting command — all validation was `nix build`/`nix eval`, plus disposable `pct`/`qm` create-then-destroy cycles via the repo's own `create-proxmox-resource.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Static Analysis Results — all clean
|
||||
|
||||
`bash scripts/codex-maintenance.sh --full-check --dry-run` (whole-tree sweep, not just changed files):
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Secret grep | Clean — only the documented exceptions (installer's own hashed passwords, `access-tokens` comment references) |
|
||||
| `nixpkgs-fmt --check` | 0/53 files would be reformatted |
|
||||
| `statix` | No lint warnings |
|
||||
| nix-cache host key drift check | Up to date |
|
||||
| Full eval of every host's `system.build.toplevel` | All 19 `nixosConfigurations` targets evaluate cleanly |
|
||||
| Dry-run build of every host + package | All succeed, no derivation errors |
|
||||
|
||||
No drift, no formatting issues, no lint findings anywhere in the tree.
|
||||
|
||||
---
|
||||
|
||||
## 2. Per-Target Test Results
|
||||
|
||||
Legend: **LIVE** = built on pve, `pct`/`qm` create → interrogated → destroyed. **BUILD-ONLY** = `nix build` validated the config (mostly `.config.system.build.toplevel`, occasionally `.tarball`), no resource created on pve.
|
||||
|
||||
| Target | Test type | Result | Notes |
|
||||
|---|---|---|---|
|
||||
| `lxc-docker` | BUILD-ONLY | ✅ PASS | Live redeploy skipped — CT105 is already running this identity in production; `--allow-duplicate-host` would have destroyed it. |
|
||||
| `lxc-minimal` | **LIVE** | ✅ PASS (after retry) | First attempt reused a stale cached tarball predating a same-day sops-key commit → activation failed, sshd never started (see Finding #2). Redeployed with `--force-rebuild`: clean boot, `systemctl is-system-running` = `running`, secrets decrypted, sshd listening, users correct. |
|
||||
| `lxc-nix-cache` | BUILD-ONLY | ✅ PASS (after retry) | Live redeploy skipped — CT101 is already running this identity. First local build attempt appeared to hang on a remote-builder handoff to nix-cache; killed and retried with `--builders ""` (local-only), succeeded. |
|
||||
| `lxc-gui` | **LIVE (attempted)** | ⚠️ BLOCKED by pve disk space | Registered a fresh sops key (no prior registration existed), built successfully through the full NixOS system closure, then **failed packaging the tarball**: `No space left on device` on pve's root filesystem. Not a flake defect. |
|
||||
| `lxc-pxe-boot` | **LIVE (attempted)** | ⚠️ BLOCKED by pve disk space | Same failure as `lxc-gui` — this target additionally builds a full nested installer/netboot image (`stage-installer-artifacts.nix`), making it similarly large. Failed with the same `No space left on device` error, immediately after the gui attempt had already consumed pve's remaining headroom. |
|
||||
| `lxc-server` | BUILD-ONLY | ✅ PASS | No sops key registered yet; live deploy also would have hit `boot.zfs.extraPools` trying to import a real ZFS pool that doesn't exist in an isolated test container — an expected limitation of testing this build type outside its real hardware, not a bug. |
|
||||
| `lxc-tailscale-exit-node` | BUILD-ONLY | ✅ PASS | No sops key registered yet. |
|
||||
| `lxc-tor-relay` | BUILD-ONLY | ✅ PASS | Live redeploy skipped — CT106 already holds this identity in production. |
|
||||
| `proxmox-docker` | BUILD-ONLY | ✅ PASS (after retry) | Live redeploy skipped — both CT105 *and* VM103 already hold `docker` identities. Combined `toplevel` + `diskoImagesScript` build crashed with a **Nix-internal assertion failure** (`worker.cc:360`) under this session's memory pressure (see Finding #6) — not a flake bug. Retried with `toplevel` alone: clean. |
|
||||
| `proxmox-minimal` | **LIVE (attempted)** | ⚠️ BLOCKED by key drift → BUILD-ONLY | `.sops.yaml` has a registered `&proxmox-minimal` anchor but **no corresponding private key exists anywhere in this environment** — the script correctly refused to generate a mismatched replacement. Fell back to `toplevel` build: ✅ PASS. |
|
||||
| `proxmox-nix-cache` | BUILD-ONLY | ✅ PASS | No sops key registered yet. |
|
||||
| `proxmox-gui` | BUILD-ONLY | ⚠️ Killed after ~40min (resource-limited) | This session's local build machine has only 2GB RAM; swap filled completely (2.0/2.0GB) and the build stalled, so it was killed rather than risk destabilizing the session further. **Not a flake defect** — the equivalent `gui` NixOS configuration already proved fully buildable during the `lxc-gui` live attempt above (it built the entire system closure successfully and only failed at the pve-side tarball-packaging step due to disk space, not the config). |
|
||||
| `proxmox-pxe-boot` | BUILD-ONLY | ⚠️ Killed after ~35min (resource-limited) | Was deep into building the nested installer's kernel initrd (this build type bundles a full netboot installer image via `stage-installer-artifacts.nix`) when killed to keep the audit moving. **Not a flake defect** — this target's own module logic was already effectively validated via the earlier *live* pve deploy attempt (`lxc-pxe-boot` above), which built the complete image and only failed at the final tarball-packaging step due to pve's disk space (Finding 1). |
|
||||
| `proxmox-server` | BUILD-ONLY | ✅ PASS | No sops key registered yet; same ZFS-pool caveat as `lxc-server` would apply to a live deploy. |
|
||||
| `proxmox-tailscale-exit-node` | BUILD-ONLY | ✅ PASS | No sops key registered yet. |
|
||||
|
||||
**Not tested at all:** `linode-*` targets (not deployable to Proxmox) and `installer` (not a normal host) — both were still covered by the static eval/dry-run-build sweep above.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings, Ranked by Severity
|
||||
|
||||
### Finding 1 — pve.sweet.home is critically low on disk space (blocks real deployments)
|
||||
|
||||
At session start: `/dev/mapper/pve-root` was **95% full, 5.3GB free** (of 94GB). After two failed large builds it recovered slightly to **91% full, 8.2GB free** (nix cleans up its own failed-build scratch space). `/nix/store` alone is 26GB; `nix-store --gc --print-dead` reports **zero** reclaimable garbage — everything currently in the store is a live GC root, so `nix-collect-garbage` won't help without first removing old roots.
|
||||
|
||||
**Why it matters:** `create-proxmox-resource.sh` builds every VM/CT image **directly on pve**, not on a build machine and transferred over. With <10GB headroom, any closure approaching a few GB (the `gui` build type: full Cinnamon desktop + Firefox + LibreOffice + GIMP + VS Code + xrdp; the `pxe-boot` build type: nginx/atftpd *plus* an entire nested installer/netboot image) cannot currently be built there at all. Both `lxc-gui` and `lxc-pxe-boot` failed live with `No space left on device` during this audit.
|
||||
|
||||
**Recommended action:** Expand `pve-root`'s LV, or free space by pruning old container templates in `/var/lib/vz/template/cache` (1.5GB) / old backups in `/var/lib/vz/dump` (306MB) / auditing what's pinning 26GB of `/nix/store` as live GC roots (likely `result-*` symlinks — see below). This is real production disk state; **not something this session touched or fixed** — it needs the operator's judgment on what's safe to remove.
|
||||
|
||||
**Secondary, smaller finding:** every `create-proxmox-resource.sh` run leaves a `result-<target>` symlink in the node's repo checkout as a permanent GC root (`ls /root/nixos/result-*` on pve showed 3 from this session alone: `lxc-docker`, `lxc-minimal`, `lxc-nix-cache`). These accumulate forever and pin their entire closures in the store. Consider having the script clean up its own `result-*` link after staging the built artifact (or use a temp `--out-link` under `/tmp`), so `nix-collect-garbage` can actually reclaim old build outputs.
|
||||
|
||||
### Finding 2 — Stale cached images can silently ship broken secrets (reproduced live)
|
||||
|
||||
`create-proxmox-resource.sh`'s default behavior is: if the node already has `<target>.tar.xz`/`.raw` staged, **reuse it** — only `--force-rebuild` forces a fresh build. This session hit exactly the failure mode `docs/auto-installer.md` already warns about: `lxc-minimal`'s cached tarball (built 2026-07-20T15:57Z) predated a same-day sops-key fix commit (2026-07-20T17:49Z, "clean up in ailse 3"). The deployed container booted with:
|
||||
|
||||
```
|
||||
sops-install-secrets: failed to decrypt '.../common.yaml': Error getting data key: 0 successful groups required, got 0
|
||||
Activation script snippet 'setupSecrets' failed (1)
|
||||
```
|
||||
|
||||
— every secret permanently failed to decrypt, `sshd` never started (though the container otherwise looked "running"). This was **not a code bug**: the currently-committed `secrets/common.yaml` decrypts fine for that host's key when checked independently; the *cached artifact on pve* simply reflected an older commit's ciphertext. Redeploying with `--force-rebuild` fixed it immediately.
|
||||
|
||||
**Why it matters:** this is silent and easy to trigger by accident — any operator who redeploys a host without remembering `--force-rebuild` after a secrets change gets a container that looks like it started (`pct start` succeeds, `pct status` = running) but is completely inaccessible.
|
||||
|
||||
**Recommended action:** Have `create-proxmox-resource.sh` compare the cached image's build timestamp (or embed the source commit hash in the staged filename) against current HEAD, and warn (or refuse without `--force-rebuild`) if they differ — rather than silently trusting presence alone.
|
||||
|
||||
### Finding 3 — sops key/anchor drift
|
||||
|
||||
Two concrete instances hit live during this session:
|
||||
|
||||
- **`proxmox-minimal`**: `.sops.yaml` already has a registered `&proxmox-minimal` age recipient, but this environment's `host-keys/` directory has no corresponding private key file. `sync-host-keys.sh` correctly refused to generate a replacement (it would silently mismatch whatever's already registered/deployed) — but this means **no environment currently has this host's private key**, unless it exists on some other machine that was never backed up here.
|
||||
- **`lxc-gui`**, and by the same logic `lxc-server`/`lxc-tailscale-exit-node`/most `proxmox-*` targets, have **no sops registration at all yet** — expected for undeployed hosts per `docs/auto-installer.md`, but this session's live-testing needed to register `lxc-gui`'s key on the fly, which immediately hit **Finding 3b**: registering a key locally does nothing for pve's build until it's pushed to `origin/main` (pve builds via `git pull`, not from this uncommitted worktree). This is exactly gap #4 the management-surface audit (below) already flagged in the abstract — this session hit it concretely.
|
||||
|
||||
**Recommended action:** for `proxmox-minimal`, decide whether to regenerate its key (destroying old-key decrypt access, if anything still holds it) or track down wherever the original private key lives and back it up here. For the general pattern, see the management-surface audit's recommendation to pre-flight-check key registration before building.
|
||||
|
||||
### Finding 4 — QEMU guest agent never wired up (found and fixed this session)
|
||||
|
||||
`modules/common/configuration.nix:44` sets `services.qemuGuest.enable = true` on every host — the guest-side agent daemon is correctly enabled everywhere. But `scripts/proxmox/create-proxmox-resource.sh`'s `qm create` call never passed `--agent 1`, so **Proxmox never created the virtio-serial channel** the agent needs. Every `proxmox-*` VM this script ever created was silently missing `qm guest exec`/IP-address reporting in the Proxmox UI, despite the guest daemon actually running.
|
||||
|
||||
**Status: fixed in this session's worktree** (`scripts/proxmox/create-proxmox-resource.sh`, `qm create` now includes `--agent enabled=1`) — see the diff, included in the PR from this session.
|
||||
|
||||
### Finding 5 — Orphaned container on pve (CT102)
|
||||
|
||||
`pve.sweet.home` has a stopped LXC container, **VMID 102**, with an essentially empty config (`lock: create` and nothing else — no hostname, no rootfs, no network) — the leftover of a `pct create` that started and never finished. It predates this session (not created by any of this audit's activity) and wasn't touched. **Recommend the operator confirm it's abandoned and remove it** (`pct destroy 102 --purge 1`) — left as-is it may be someone's genuine in-progress work, so it wasn't assumed safe to delete autonomously.
|
||||
|
||||
### Finding 6 — Nix-internal crash under memory pressure (tooling, not flake)
|
||||
|
||||
Building `proxmox-docker`'s `toplevel` and `diskoImagesScript` together crashed with a Nix-internal assertion failure (`Assertion '!awake.empty()' failed ... worker.cc:360`, a known class of bug in Nix's multi-goal build scheduler) while this session's 2GB-RAM build container was under heavy swap pressure (1.8-2.0/2GB swap in use) from a separate concurrent build. Retrying the same target alone (no concurrency) succeeded cleanly. **Not a flake defect** — purely an artifact of this session's constrained build environment; noted for completeness since it looked alarming in isolation.
|
||||
|
||||
### Finding 7 — Management-surface audit: 5 operability gaps
|
||||
|
||||
A focused audit of "can the operator actually run this repo day to day" (flake, home-manager, sops, related scripts) found:
|
||||
|
||||
1. **No documented recovery path if the `&admin` sops age key is lost without a backup.** `scripts/secrets/backup-admin-key.sh` exists and works but is referenced nowhere in `README.md`/`docs/` — no forcing function ensures a backup was ever taken. `rotate-admin-key.sh` requires the *old* key to re-key; there's no bootstrap-from-nothing path documented (the real fallback — deriving an age identity from any still-live host's own SSH key — isn't written down anywhere).
|
||||
2. **home-manager has no standalone iteration path.** It's wired only inside `nixosConfigurations` (`flake.nix`) — no `homeConfigurations` output. The fastest real shortcut (`nix build .#nixosConfigurations.<target>.config.home-manager.users.nixos.home.activationPackage`) isn't documented anywhere, so the practical workflow is a full host rebuild to test one HM tweak.
|
||||
3. **Gitea's flake-lock-update workflow pushes straight to `main` with no pre-merge validation.** `.gitea/workflows/update-flake-lock.yml` commits and pushes `nix flake update`'s result directly; `codex-maintenance.sh` only runs *after*, on the resulting push — a genuinely broken lockfile bump lands on `main` before anything catches it. (The GitHub-side workflow is safer — PR-based — but has the opposite gap: nothing alerts if the PR sits unmerged.)
|
||||
4. **No pre-flight check that a build target has a registered sops key before building it.** `docs/auto-installer.md` documents the failure mode (silent, total secrets-decrypt failure) but nothing in `create-proxmox-resource.sh` refuses to proceed when it's about to build a target with no `.sops.yaml` anchor — it's on the operator to remember. This session's `lxc-gui` test hit close to this exact gap (needed the key added on the fly, mid-session).
|
||||
5. **`vars.remoteBuilderAuthorizedKeys` has the same drift risk as `vars.nixCacheHostKey`, but no checker script.** `sync-nix-cache-host-key.sh --check` guards the latter; the former (and `vars.pxeServerIp`/`vars.pbsIp`) has no equivalent — a rotated/revoked client key just silently stops working with no diagnostic pointing back here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Action Plan (priority order)
|
||||
|
||||
1. **Free up disk space on pve.sweet.home** (or expand `pve-root`). Blocking: `lxc-gui`, `proxmox-gui`, `lxc-pxe-boot`, `proxmox-pxe-boot` cannot currently be built/redeployed on this node at all.
|
||||
2. **Decide on `proxmox-minimal`'s orphaned sops key**: locate the original private key and back it up here, or accept regenerating it (breaks decrypt access for whoever/whatever currently holds the old one).
|
||||
3. **Merge this session's PR** (see below) to get the `--agent 1` fix and `lxc-gui`'s new sops registration onto `main` — required before `lxc-gui` can be live-redeployed with working secrets.
|
||||
4. **Add a staleness guard to `create-proxmox-resource.sh`'s cache-reuse path** (Finding 2) — highest-leverage fix, since it silently produces a broken-but-"running" host.
|
||||
5. **Add a pre-flight sops-anchor check to `create-proxmox-resource.sh`** (management-surface gap #4) — same root cause class as #4 above, catch it before building instead of at first boot.
|
||||
6. Investigate/clean up **CT102** on pve (Finding 5) — confirm abandoned, then remove.
|
||||
7. Document `backup-admin-key.sh` in `README.md`'s Security Notes and add the live-host-key bootstrap-recovery procedure to `docs/` (management-surface gap #1).
|
||||
8. Add pre-push validation to the Gitea flake-lock-update workflow (management-surface gap #3).
|
||||
9. Lower-priority: document the home-manager `activationPackage` shortcut (gap #2); extend `sync-nix-cache-host-key.sh`'s drift-check pattern to `remoteBuilderAuthorizedKeys` (gap #5).
|
||||
10. Follow-up session: finish build-validating `proxmox-gui` and `proxmox-pxe-boot` (both killed here after 35-40min on this session's 2GB-RAM machine — not failures, just unfinished) once pve has headroom (item 1) — ideally from a machine with more RAM. `proxmox-server` and `proxmox-tailscale-exit-node` already passed build-only validation in this session, no follow-up needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Uncommitted Changes From This Session
|
||||
|
||||
This worktree (`worktree-flake-e2e-audit`) currently has:
|
||||
|
||||
- `scripts/proxmox/create-proxmox-resource.sh` — the `--agent enabled=1` fix (Finding 4).
|
||||
- `.sops.yaml` / `secrets/common.yaml` — `lxc-gui`'s new age key registered as a recipient (generated live during this session's testing).
|
||||
|
||||
Per this session's standard workflow, these will be committed, pushed, and opened as a draft PR rather than pushed to `main` directly — merging it is the operator's call, and is also **prerequisite to live-redeploying `lxc-gui` successfully** (its build will keep hitting the sops-staleness failure from Finding 2 on pve until this registration is on `origin/main`).
|
||||
@@ -21,15 +21,17 @@ machines when deployed.
|
||||
`modules/installer/common.nix` (the auto-installer's own root/nixos login —
|
||||
a deliberate, documented choice, see `docs/auto-installer.md`, not
|
||||
accidental tech debt) and **SSH public keys** in `variables.nix`
|
||||
(`vars.adminSshKey`, `vars.remoteBuilderAuthorizedKeys`) plus a couple of
|
||||
per-host `KEY` values for beszel-agent auth (`hosts/server/host.nix`,
|
||||
`hosts/nix-cache/host.nix`). Don't use the installer's hardcoded hash as a
|
||||
(`vars.adminSshKey`, `vars.remoteBuilderAuthorizedKeys`, `vars.beszelHubKey`). Don't use the installer's hardcoded hash as a
|
||||
template for a *real* host — every other host uses sops-nix
|
||||
(`hashedPasswordFile`, see "Security Notes" in `README.md`). Flag any *new*
|
||||
secret-like string you encounter instead of committing it.
|
||||
- `host-keys/` is gitignored — locally-generated *private* SSH host keys for
|
||||
the auto-installer (see `docs/auto-installer.md`). Never commit its
|
||||
contents; if `git status` ever shows it as trackable, something is wrong.
|
||||
- `host-keys/` is gitignored — used only by the auto-installer's own
|
||||
environment for pre-seeding non-LXC host keys before first boot (see
|
||||
`docs/auto-installer.md`). Never commit its contents; if `git status`
|
||||
ever shows it as trackable, something is wrong. All deployed hosts use
|
||||
clan vars (`vars/per-machine/<target>/openssh/`, committed and
|
||||
sops-encrypted) for their SSH host keys — those ARE tracked by git and
|
||||
belong in the repo.
|
||||
|
||||
### Two Proxmox nodes: `pve1.sweet.home` (production) and `pve-test.sweet.home` (sandbox)
|
||||
|
||||
@@ -203,8 +205,11 @@ instead of copying it.
|
||||
- `scripts/secrets/sync-host-keys.sh` — generates/registers SSH host keys
|
||||
and their `.sops.yaml`/`secrets/*.yaml` recipients for flake targets,
|
||||
idempotently (`--all`, `<target>`, `--remove`, `--regenerate-all-keys`,
|
||||
all with `--dry-run`). The primary tool for provisioning a new host's
|
||||
secrets access — see "Creating a new machine" in `docs/auto-installer.md`.
|
||||
all with `--dry-run`). Stores keys as clan vars
|
||||
(`vars/per-machine/<target>/openssh/`, committed and sops-encrypted) for
|
||||
all flake targets. The primary tool for provisioning a new host's
|
||||
secrets access — see "Creating a new machine" in
|
||||
`docs/auto-installer.md`.
|
||||
- `scripts/secrets/prepare-host-key.sh` — narrower predecessor: generates a
|
||||
key by an arbitrary name without touching `.sops.yaml`. Still useful to
|
||||
pre-generate a key before its flake target exists yet, since
|
||||
@@ -247,6 +252,14 @@ instead of copying it.
|
||||
silent skip rather than a failure) only reports drift; the no-flags form
|
||||
updates both files in place. Declarative clients still need a rebuild to
|
||||
pick up the fix.
|
||||
- `scripts/secrets/push-host-keys.sh [--all | <target>] [--dry-run]
|
||||
[--skip-git-check]` — pushes newly-generated SSH host keys from
|
||||
`host-keys/` to already-running NixOS hosts, so they can decrypt sops
|
||||
secrets after a rebuild following `sync-host-keys.sh
|
||||
--regenerate-all-keys`. Verifies that `.sops.yaml` and `secrets/*.yaml`
|
||||
are committed and pushed to the remote first (hosts rebuild from the
|
||||
remote Gitea flake, so recipient changes must land there before any key
|
||||
push).
|
||||
|
||||
### `scripts/proxmox/`
|
||||
|
||||
@@ -268,6 +281,16 @@ instead of copying it.
|
||||
failure just falls back to building from source / `cache.nixos.org`) so
|
||||
the node substitutes from and can offload builds to nix-cache on every
|
||||
subsequent run, not just this one.
|
||||
- `scripts/proxmox/clone-pve1-to-pve-test.sh <vmid> [--new-vmid <id>]
|
||||
[--mode snapshot|suspend|stop] [--dry-run]` — ad-hoc clone of a single
|
||||
VM or CT from pve1 (production) to pve-test (sandbox) via vzdump +
|
||||
qmrestore/pct restore. Streams the archive directly between nodes (no
|
||||
local staging copy). Always restores with `--unique 1` (fresh MAC
|
||||
addresses) since the original is still running on the LAN. Cleans up
|
||||
the vzdump archive from both nodes after a successful restore. The
|
||||
script's own default is pve1 → pve-test, matching CLAUDE.md's policy
|
||||
(unlike `create-proxmox-resource.sh`, which defaults to production for
|
||||
the operator's own unqualified use).
|
||||
- `scripts/proxmox/configure-nix-cache-client.sh [--dry-run]
|
||||
[--no-remote-builder] [--no-restart]` — the non-NixOS equivalent of
|
||||
`modules/nix-cache/client.nix`/`remote-builder-client.nix`, for a plain
|
||||
@@ -283,6 +306,50 @@ instead of copying it.
|
||||
marked block rather than duplicating it); restarts `nix-daemon` by
|
||||
default so the change takes effect immediately.
|
||||
|
||||
### `scripts/ha/`
|
||||
|
||||
HA cluster lifecycle and operational scripts. All mutate real cluster state
|
||||
when run for real — always run against pve-test first unless the operator
|
||||
explicitly targets pve1.
|
||||
|
||||
- `scripts/ha/deploy.sh [--skip-*] [--destroy] [--dry-run]` — full
|
||||
lifecycle manager: phases through bridge creation, key sync, VM creation
|
||||
(via `create-proxmox-resource.sh`), NIC/disk attachment, and cluster
|
||||
initialisation. `--destroy` tears it back down. Safe to rerun
|
||||
idempotently; each phase can be individually skipped.
|
||||
- `scripts/ha/cluster-init.sh` — one-time cluster bootstrap run **as root
|
||||
on ha-server-1** after both VMs are booted. Generates/distributes the
|
||||
Corosync authkey, initialises DRBD metadata, creates XFS on `/dev/drbd0`,
|
||||
configures LIO iSCSI, and registers all Pacemaker resources (DRBD → XFS
|
||||
→ iSCSI → NFS → VIPs).
|
||||
- `scripts/ha/health.sh` — read-only cluster health snapshot: SSH
|
||||
reachability, quorum, DRBD state, Pacemaker resources, and VIP port
|
||||
reachability. Safe to run from the workstation at any time.
|
||||
- `scripts/ha/failover.sh [--to node1|node2] [--force] [--timeout <s>]
|
||||
[--dry-run]` — graceful failover by putting the active node into
|
||||
Pacemaker standby and waiting for resources to appear on the target.
|
||||
- `scripts/ha/acceptance-tests.sh` — T1–T7 acceptance tests (failover,
|
||||
NFS/iSCSI connectivity, DRBD sync, etc.) that must all pass before the
|
||||
cluster is considered production-ready.
|
||||
- `scripts/ha/resize-data-disk.sh --size +NNg [--force] [--dry-run]` —
|
||||
online data-disk resize: `qm resize` on both VMs, guest block-device
|
||||
rescan, `drbdadm resize`, `xfs_growfs`. No downtime required.
|
||||
- `scripts/ha/cluster-enable-stonith.sh` — enables the `fence_pve_ssh`
|
||||
STONITH resource after the fence SSH key is deployed to both nodes and
|
||||
authorised on the Proxmox host. Run once after `cluster-init.sh`.
|
||||
- `scripts/ha/fence-pve-ssh.py` — Python STONITH fence agent for Pacemaker.
|
||||
Deploy to `/etc/pacemaker/fence_pve_ssh` on both HA nodes (`chmod +x`).
|
||||
SSHes to the Proxmox host and runs `qm stop/start <vmid>`.
|
||||
|
||||
### `scripts/ipa/`
|
||||
|
||||
- `scripts/ipa/create-nixos-ipa-host-account.sh [options] <hostname>` —
|
||||
adds a NixOS host to the FreeIPA domain and produces a sops-encrypted
|
||||
keytab at `secrets/<hostname>.keytab`, ready for `modules/ipa/client.nix`.
|
||||
Replaces three error-prone manual steps: `ipa host-add`, `ipa-getkeytab`
|
||||
(run on the DC, SCP'd back), and `sops encrypt` in the correct location
|
||||
(must be at `secrets/<hostname>.keytab` for the creation rule to match).
|
||||
|
||||
### `scripts/lib/`
|
||||
|
||||
Sourced by the scripts above, never run directly:
|
||||
@@ -292,6 +359,15 @@ Sourced by the scripts above, never run directly:
|
||||
`create-proxmox-resource.sh` runs over SSH.
|
||||
- `nix-eval.sh` — `NIX_EVAL_FLAGS` plus `list_flake_targets`/
|
||||
`flake_target_hostname` flake-introspection helpers.
|
||||
- `nix-parallel.sh` — `run_nix_parallel`: fans out independent `nix eval`/
|
||||
`nix build --dry-run` calls across up to `NIX_PARALLEL_JOBS` processes,
|
||||
capped by available memory (~1 GB/job) rather than raw `nproc` to avoid
|
||||
OOM on constrained CI runners. Used by `codex-maintenance.sh`.
|
||||
- `clan-vars.sh` — helpers for reading/writing SSH host keys stored as clan
|
||||
vars (`vars/per-machine/<target>/openssh/`, sops-encrypted) instead of
|
||||
the gitignored `host-keys/` directory. Sourced by
|
||||
`create-proxmox-resource.sh` and `sync-host-keys.sh`; depends on
|
||||
`sops-age.sh` and `ssh-host-keys.sh` being sourced first.
|
||||
- `ssh-host-keys.sh` — `generate_host_ed25519_key`/`ssh_pubkey_to_age`,
|
||||
shared by `sync-host-keys.sh` and `prepare-host-key.sh`.
|
||||
- `sops-age.sh` — `age_pubkey_from_identity_file`/`sops_yaml_admin_pubkey`/
|
||||
@@ -311,6 +387,17 @@ Sourced by the scripts above, never run directly:
|
||||
default cores/memory, `NIX_CACHE_HOST`, `LAN_DOMAIN`) sourced by
|
||||
`create-proxmox-resource.sh` and `scripts/installer/auto-install.sh`. Add
|
||||
new cross-script config here instead of duplicating it per-script.
|
||||
- `scripts/recover-hosts.sh [<hostname> ...]` — fixes sops/SSH-key/GitHub-token
|
||||
issues on deployed NixOS hosts and triggers a `Switch-nix` rebuild on each.
|
||||
With no args discovers every known hostname; with args checks only those.
|
||||
Fixes applied automatically (prompts before rebuilding): SSH host key drift
|
||||
(restores the registered key) and stale GitHub access tokens (empties the
|
||||
rendered `nix-github-token.conf` so Nix falls back to unauthenticated requests
|
||||
until sops-nix re-renders the correct token after the next successful rebuild).
|
||||
- `scripts/gc-hosts.sh [--dry-run]` — runs `nix-collect-garbage -d` on all live
|
||||
NixOS hosts (workstation first, then pve1, then all Proxmox guests). Excludes
|
||||
`nix-cache` (gc-ing the shared binary cache evicts store paths other hosts
|
||||
depend on). Uses passwordless sudo where available; falls back to user-level gc.
|
||||
- `scripts/bump-nixpkgs-release.sh` — bumps `flake.nix`'s `nixpkgs.url`/
|
||||
`home-manager.url` in place. Exists because flake input URLs can't
|
||||
reference `variables.nix` (confirmed empirically — `nix flake metadata`
|
||||
@@ -349,12 +436,13 @@ nixosSystem {
|
||||
```
|
||||
|
||||
Platforms: `linode`, `proxmox`, `lxc`, `baremetal`. Build types: `minimal`,
|
||||
`nix-cache`, `server`, `docker`, `gui`, `pxe-boot`, `tailscale-exit-node`,
|
||||
`tor-relay`. Not every combination is built — e.g. `pxe-boot` has no `linode`
|
||||
`nix-cache`, `docker`, `gui`, `pxe-boot`, `tailscale-router`, `tor-relay`,
|
||||
`ha-server`. Not every combination is built — e.g. `pxe-boot` has no `linode`
|
||||
variant (PXE/DHCP/TFTP need LAN L2 adjacency a Linode VPS doesn't have),
|
||||
`tor-relay` currently only exists as `lxc-tor-relay`, and `baremetal`
|
||||
currently only exists as `baremetal-gui` (the real gui-host hardware —
|
||||
see `hosts/nixos/host.nix` and `modules/platforms/baremetal.nix`). Treat
|
||||
`tor-relay` only exists as `lxc-tor-relay`, `ha-server` only exists as
|
||||
`proxmox-ha-server-{1,2}`, and `baremetal` only exists as `baremetal-gui`
|
||||
(the real gui-host hardware — see `hosts/nixos/host.nix` and
|
||||
`modules/platforms/baremetal.nix`). Treat
|
||||
`flake.nix`'s
|
||||
`generatedTargets` as the source
|
||||
of truth for which hosts exist — `README.md`, `AGENTS.md`,
|
||||
@@ -368,9 +456,8 @@ removing a host.
|
||||
|
||||
- `hosts/<name>/host.nix` — per-machine identity **only**: hostname, hostId,
|
||||
per-machine secrets, `system.stateVersion`. These files carry no `imports`
|
||||
of their own beyond narrow parameterized helpers (see
|
||||
`modules/beszel/host-token.nix` below) — all shared behavior comes from the
|
||||
platform/build-type modules composed in `flake.nix`, not from the host file.
|
||||
of their own — all shared behavior comes from the platform/build-type modules
|
||||
composed in `flake.nix`, not from the host file.
|
||||
- `modules/platforms/{linode,proxmox,lxc,baremetal}.nix` — platform-specific
|
||||
config: boot method, guest tooling, and the hardware config, imported
|
||||
directly by the platform module itself — **not** wired in from
|
||||
@@ -387,7 +474,7 @@ removing a host.
|
||||
`vzdump` backup-archive metadata this doesn't have), no install step —
|
||||
see `docs/auto-installer.md`.
|
||||
- `modules/build-types/*.nix` — what a system is for:
|
||||
minimal/server/docker/gui/pxe-boot/nix-cache/tailscale-exit-node/tor-relay.
|
||||
minimal/docker/gui/pxe-boot/nix-cache/tailscale-router/tor-relay/ha-server.
|
||||
- `modules/common/configuration.nix` — base NixOS config imported by every
|
||||
host: locale, users, nix settings, git.
|
||||
- `modules/common/home.nix` / `hosts/nixos/home.nix` — Home Manager config for
|
||||
@@ -413,7 +500,7 @@ removing a host.
|
||||
`modules/platforms/baremetal.nix` also imports
|
||||
`modules/services/zfs/enable-service.nix` for this (the `zfs_unstable`
|
||||
package, autoScrub/autoSnapshot/trim) — the only other importer today is
|
||||
`server`'s NFS data pool, an unrelated non-root ZFS use.
|
||||
`ha-server`'s NFS data pool, an unrelated non-root ZFS use.
|
||||
- `modules/boot/efi.nix` — systemd-boot + EFI vars, paired with the disko module.
|
||||
- `modules/installer/` — the auto-installer environment (ISO, also served as
|
||||
PXE netboot): `common.nix` (shared config + the generated
|
||||
@@ -427,14 +514,21 @@ removing a host.
|
||||
substituter + SSH remote-builder wiring; see `docs/nix-cache.md` for the
|
||||
full design (per-host local stores, no shared `/nix/store`, and how the
|
||||
`nixremote` signing/SSH keys fit together).
|
||||
- `modules/beszel/host-token.nix` — parameterized helper module
|
||||
(`{ name, sopsFile }`) that wires a host's beszel-agent sops secret/template
|
||||
and `environmentFile`; used by `hosts/server/host.nix` and
|
||||
`hosts/nix-cache/host.nix` to avoid duplicating that boilerplate.
|
||||
- `modules/ha/` — HA cluster NixOS modules: `cluster-config.nix` (DRBD,
|
||||
Corosync, Pacemaker, firewall rules, cluster-wide NFS/iSCSI port
|
||||
authorisation — shared by both ha-server nodes), `pacemaker-stack.nix`
|
||||
(Pacemaker + Corosync service enablement), and supporting modules. See
|
||||
`docs/ha.md` for the cluster operational guide.
|
||||
- `modules/ipa/client.nix` — FreeIPA client enrollment: sssd, Kerberos keytab,
|
||||
and IPA host registration; imported by every real host via
|
||||
`modules/common/configuration.nix`.
|
||||
- `modules/beszel/enable-agent.nix` — enables beszel-agent, sets `HUB_URL`,
|
||||
fixes the upstream `StateDirectory` bug, and wires the universal
|
||||
`beszel-token` sops secret (from `secrets/common.yaml`) into the agent's
|
||||
`environmentFile`; see `docs/beszel.md` for the full setup guide.
|
||||
- `modules/tailscale/`, `modules/docker/`, `modules/networking/`,
|
||||
`modules/traefik/`, `modules/tor/`, `modules/services/*` — single-purpose,
|
||||
single-host
|
||||
feature modules (e.g. `docker/enable-service.nix`,
|
||||
single-host feature modules (e.g. `docker/enable-service.nix`,
|
||||
`services/zfs/enable-service.nix`). Grep `modules/build-types/*.nix` for
|
||||
each build type's `imports` list to see which modules apply where.
|
||||
|
||||
@@ -458,3 +552,5 @@ duplicating config.
|
||||
- `docs/flake-lock-automation.md` — how `flake.lock` updates flow through CI
|
||||
(scheduled `nix flake update` PR + host-eval-on-PR workflow) and why hosts
|
||||
should track the committed lock file rather than `nixos-rebuild --upgrade-all`.
|
||||
- `docs/ha.md` — HA file-server cluster: DRBD + XFS + LIO iSCSI + NFS managed
|
||||
by Corosync + Pacemaker; network topology; lifecycle scripts in `scripts/ha/`.
|
||||
|
||||
@@ -9,14 +9,14 @@ Targets are named `<platform>-<buildtype>`, generated from two orthogonal
|
||||
pieces composed in `flake.nix`:
|
||||
|
||||
- **Platforms** (what it runs on): `linode`, `proxmox`, `lxc`, `baremetal`
|
||||
- **Build types** (what it's for): `minimal`, `nix-cache`, `server`, `docker`,
|
||||
`gui`, `pxe-boot`, `tailscale-exit-node`, `tor-relay`
|
||||
- **Build types** (what it's for): `minimal`, `nix-cache`, `docker`, `gui`,
|
||||
`pxe-boot`, `tailscale-router`, `tor-relay`, `ha-server`
|
||||
|
||||
Not every combination exists — `pxe-boot` has no `linode` variant, since
|
||||
PXE/DHCP/TFTP need LAN L2 adjacency that a Linode VPS doesn't have,
|
||||
`tor-relay` currently only exists as `lxc-tor-relay`, and `baremetal`
|
||||
currently only exists as `baremetal-gui` (the real gui-host hardware). The
|
||||
full list:
|
||||
`tor-relay` and `ha-server` currently only exist on `lxc`/`proxmox`, and
|
||||
`baremetal` currently only exists as `baremetal-gui` (the real gui-host
|
||||
hardware). The full list:
|
||||
|
||||
| Target | Purpose |
|
||||
| --- | --- |
|
||||
@@ -24,13 +24,13 @@ full list:
|
||||
| `proxmox-minimal` | Minimal NixOS host profile on Proxmox — previously the flat `nix-minimal` target |
|
||||
| `lxc-minimal` | Minimal NixOS host profile in a Proxmox LXC container |
|
||||
| `linode-nix-cache` / `proxmox-nix-cache` / `lxc-nix-cache` | Local Nix binary cache and remote builder — previously the flat `nix-cache` target |
|
||||
| `linode-server` / `proxmox-server` / `lxc-server` | Storage, NFS, backup, and monitoring exporter host — previously the flat `server` target |
|
||||
| `linode-docker` / `proxmox-docker` / `lxc-docker` | Docker host for the main container stack — previously the flat `docker` target |
|
||||
| `linode-gui` / `proxmox-gui` / `lxc-gui` | Cinnamon desktop workstation — previously the flat `nixos` target |
|
||||
| `baremetal-gui` | Same Cinnamon desktop workstation, on the real gui-host hardware — ZFS RAID0 root, systemd-boot |
|
||||
| `proxmox-pxe-boot` / `lxc-pxe-boot` | HTTP/iPXE boot asset host — previously the flat `pxe-boot` target |
|
||||
| `linode-tailscale-exit-node` / `proxmox-tailscale-exit-node` / `lxc-tailscale-exit-node` | Tailscale exit node |
|
||||
| `linode-tailscale-router` / `proxmox-tailscale-router` / `lxc-tailscale-router` | Tailscale subnet router + MagicDNS forwarder for the LAN |
|
||||
| `lxc-tor-relay` | Tor middle relay |
|
||||
| `proxmox-ha-server-1` / `proxmox-ha-server-2` | HA file-server cluster nodes — DRBD + XFS + iSCSI + NFS, managed by Corosync + Pacemaker |
|
||||
|
||||
Which variant of a given buildtype is actually deployed isn't tracked
|
||||
anywhere in this repo — that's live infrastructure state, not something a
|
||||
@@ -47,8 +47,7 @@ section for which is which.
|
||||
|
||||
Each buildtype's `hosts/<name>/host.nix` carries the per-machine identity
|
||||
(hostname, hostId, per-machine secrets, `system.stateVersion`) that must stay
|
||||
fixed regardless of which platform it's built for — see
|
||||
`flake-target-refactor-spec.md` for the full rationale. Every deployed host
|
||||
fixed regardless of which platform it's built for. Every deployed host
|
||||
stamps its own active target name into `/etc/flake-target` at build time, so
|
||||
`nixos-rebuild switch --flake .#$(cat /etc/flake-target)` always picks up the
|
||||
right one even after a platform migration changes the flake attribute name.
|
||||
@@ -72,7 +71,8 @@ nix eval --json .#nixosConfigurations --apply builtins.attrNames | jq -r '.[]'
|
||||
| `modules/common/` | Shared NixOS config, Home Manager, aliases imported by every host |
|
||||
| `modules/nix-cache/` | Binary cache and remote builder client/server modules |
|
||||
| `modules/installer/` | Auto-installer environment (ISO, also served as PXE netboot) — see `docs/auto-installer.md` |
|
||||
| `host-keys/` | Gitignored, locally-generated SSH host keys for the auto-installer — see `docs/auto-installer.md` |
|
||||
| `host-keys/` | Gitignored; only used by the auto-installer environment for pre-seeding SSH host keys before first boot — see `docs/auto-installer.md`. All deployed hosts use clan vars (`vars/per-machine/<target>/openssh/`) instead |
|
||||
| `vars/per-machine/` | Clan vars: committed, sops-encrypted SSH host keys for all deployed hosts; read by `create-proxmox-resource.sh` at deploy time |
|
||||
| `docs/` | Operational notes for cache, builders, lock updates, boot services, the auto-installer, and Proxmox image builds |
|
||||
| `scripts/` | Codex setup, validation, host-key, release-bump, and Proxmox resource helpers |
|
||||
|
||||
@@ -162,9 +162,10 @@ sops-nix-everywhere: it has a hardcoded login password instead (no stable
|
||||
per-boot host key for sops-nix to derive from on ephemeral media) — see
|
||||
"Host keys" in `docs/auto-installer.md` for why, and how the private keys it
|
||||
*does* pre-seed for target hosts stay out of git via the gitignored
|
||||
`host-keys/` directory.
|
||||
`host-keys/` directory. All deployed hosts use clan vars
|
||||
(`vars/per-machine/<target>/openssh/`, committed and sops-encrypted) for
|
||||
their SSH host keys.
|
||||
|
||||
This repository's git *history* still contains secrets committed before this
|
||||
migration (see `remove-sensetive-info-refactor.md`) — those are being
|
||||
scrubbed and rotated separately; don't treat the repo as safe to make public
|
||||
until that's finished.
|
||||
This repository's git *history* still contains secrets committed before the
|
||||
sops-nix migration — those are being scrubbed and rotated separately; don't
|
||||
treat the repo as safe to make public until that's finished.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIESDCCArCgAwIBAgIBATANBgkqhkiG9w0BAQsFADA1MRMwEQYDVQQKDApTV0VF
|
||||
VC5IT01FMR4wHAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjYwNzI2
|
||||
MjExMzQxWhcNNDYwNzI2MjExMzQxWjA1MRMwEQYDVQQKDApTV0VFVC5IT01FMR4w
|
||||
HAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggGiMA0GCSqGSIb3DQEBAQUA
|
||||
A4IBjwAwggGKAoIBgQCzljYktbHdMGVJ6Wq0XQJuHLN6dkCSOgtoIzQtriPQkkNI
|
||||
uo28LwobaiQQ8sX4kGRH/BTKnH8QlId/jug4Uc+sDHnABYu++AiOhPbBX8gCpRQ0
|
||||
hebBjZiktHSBUEJR31siWOVdBoKBDJEoxehx7XUXvcxIJcaRN+LHYjO86nJN55HB
|
||||
VwFU2JcYDk98c+144dFJxXdr++MjWe4Z/oVVU8JHIOtNtKhVhvij6oOSWxcYoJO/
|
||||
S80LRj1vx/o6o/3G6bYug7PjY7JjZk/Oj61whijZkcsoO1MXSYI6UywJZGflv+ZB
|
||||
7HyufdYAsK3WhE8O2FX3/kq64Ol83HNtoR8Dt68rTg1xpW6K45jS6iDPKueYGkb0
|
||||
oSx7e++90VAW2PDhj6QQ3JJ4O5VQwrrecekJzUrAean0FOEbmgyi4PsEp1Vk6LDQ
|
||||
SsIn1x0euyxVivQMlzNX2XrZL3urn1BNPqAdntXQMkR0Wl8sbUiJPe0kxG52CGXs
|
||||
6yfNEXbPmVGcC0TBdGECAwEAAaNjMGEwHQYDVR0OBBYEFLh5QbI1UWMH0WR4z8bG
|
||||
lhrOX3X5MB8GA1UdIwQYMBaAFLh5QbI1UWMH0WR4z8bGlhrOX3X5MA8GA1UdEwEB
|
||||
/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgHGMA0GCSqGSIb3DQEBCwUAA4IBgQCVodVN
|
||||
owwo53OQe02QhtEbIur2PL7zIfvhvCTRD4J8gwpbMIqT7JQK0tV6Mvsg2L8yTb2O
|
||||
KjrWeLKHGWaZZlhGSPTbkMFdb/Ls8M9FSnkc2bwcdWW3Z1lOiCjBYYqwLCG6JhvB
|
||||
5SXVwWNJwXeasL2m7oFTSwhsqPpARJ2t25u2N35o+tqIoCjijKwkmEOT66N9EAbu
|
||||
2VQjtYZWPkBtP4YCe0Ey6u4oy7sy8ThNAjOylZok+J4JW7QEFjK4Q/emhA4aQq5H
|
||||
gg9qgMuG+5oi6D1g2Wy+fMTRBaukJtLYZbBpQMQhMYWg44uPp/2bbNPTID/nV1KB
|
||||
GcPyHaskcVxPdYWxAPMwk3AeJXWyOq7atAPTF5sbk0kQQf2m+vyOqcli5CxRMUgV
|
||||
rcyi9l6+dZW4U+38Q0ET5M3OuxNI4hA7kVY2cfTakXWNqh97+TIHnstblDhAxECK
|
||||
6ZLMJQYUy7LqJTX84H27CBWLexEMjXwdr5HCV88Fj6mAK0fRufnIw5FeneA=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -22,7 +22,7 @@ see "LXC hosts" immediately below for why those are different.**
|
||||
## LXC hosts
|
||||
|
||||
`lxc-*` targets (`lxc-minimal`, `lxc-nix-cache`, `lxc-server`, `lxc-docker`,
|
||||
`lxc-gui`, `lxc-pxe-boot`, `lxc-tailscale-exit-node`, `lxc-tor-relay`) are **not** installed via `auto-install.sh` — the
|
||||
`lxc-gui`, `lxc-pxe-boot`, `lxc-tailscale-router`, `lxc-tor-relay`) are **not** installed via `auto-install.sh` — the
|
||||
interactive menu deliberately excludes them. Don't try to select one there;
|
||||
`nixos-install` would bind-mount `/` onto `/mnt` (LXC containers have no raw
|
||||
disk to partition) and then refuse to touch the filesystem it's currently
|
||||
@@ -133,13 +133,15 @@ Flake outputs:
|
||||
```nix
|
||||
nixosConfigurations.installer # ISO/netboot installer image
|
||||
|
||||
packages.x86_64-linux.iso # installer ISO/netboot image
|
||||
packages.x86_64-linux.pxe # netboot-ipxe + netboot-initrd + netboot-kernel, bundled
|
||||
packages.x86_64-linux.iso # installer ISO/netboot image
|
||||
packages.x86_64-linux.pxe # auto-installer netboot bundle (kernel + initrd + ipxe script)
|
||||
packages.x86_64-linux.pxe-minimal # vanilla NixOS minimal netboot bundle (no installer wiring)
|
||||
```
|
||||
|
||||
```sh
|
||||
nix build .#iso
|
||||
nix build .#pxe
|
||||
nix build .#pxe-minimal
|
||||
```
|
||||
|
||||
There's no `nixosConfigurations.proxmox-lxc` (installer-boots-as-an-LXC-
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Beszel agent
|
||||
|
||||
[Beszel](https://github.com/henrygd/beszel) is the monitoring dashboard used
|
||||
in this LAN. The hub runs as a Docker container on `docker.sweet.home` (port
|
||||
`vars.ports.beszelHub`, 8090). Each monitored NixOS host runs a
|
||||
`beszel-agent` that connects back to the hub.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Everything is handled by a single module:
|
||||
|
||||
**`modules/beszel/enable-agent.nix`** — imported by a build type. It:
|
||||
- Enables `beszel-agent`
|
||||
- Sets `HUB_URL` to `docker.sweet.home:8090`
|
||||
- Sets `KEY` from `vars.beszelHubKey` (`variables.nix`) — the hub's SSH
|
||||
public key, shared by every agent. Update `beszelHubKey` if the docker
|
||||
host is ever rebuilt and the hub generates a new keypair.
|
||||
- Reads the universal `beszel-token` from `secrets/common.yaml` via sops
|
||||
and passes it to the agent as `TOKEN` in an env file
|
||||
- Fixes an upstream bug where the agent couldn't persist its hub-pairing
|
||||
fingerprint across restarts (adds a real `StateDirectory`)
|
||||
|
||||
A host file needs no beszel configuration at all — just import the module
|
||||
in the build type and add the system in the hub UI.
|
||||
|
||||
---
|
||||
|
||||
## Adding beszel to a new build type
|
||||
|
||||
Add `../beszel/enable-agent.nix` to the `imports` list in
|
||||
`modules/build-types/<type>.nix`:
|
||||
|
||||
```nix
|
||||
imports = [
|
||||
../beszel/enable-agent.nix
|
||||
# ... other imports
|
||||
];
|
||||
```
|
||||
|
||||
That's the only change required. The host file needs nothing.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new system to the hub
|
||||
|
||||
1. Rebuild and deploy the host with its build type importing `enable-agent.nix`.
|
||||
2. Open the beszel hub (`http://docker.sweet.home:8090`).
|
||||
3. Go to **Systems → Add system**, enter the host's IP and the default port
|
||||
(45876). The agent will connect and the system will appear as active.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup: add the token to `secrets/common.yaml`
|
||||
|
||||
The universal token is stored once in the common secrets file, shared by all
|
||||
agents. Only needed once, not per-host:
|
||||
|
||||
```sh
|
||||
sops secrets/common.yaml
|
||||
```
|
||||
|
||||
Add:
|
||||
```yaml
|
||||
beszel-token: <token from the beszel hub Settings → Keys>
|
||||
```
|
||||
|
||||
`secrets/common.yaml` is already a sops recipient for every host via their
|
||||
SSH host keys, so no additional sops recipient setup is needed.
|
||||
|
||||
---
|
||||
|
||||
## Optional: monitoring extra filesystems
|
||||
|
||||
To report disk usage for a mount beyond the root filesystem, add
|
||||
`EXTRA_FILESYSTEMS` in the host file:
|
||||
|
||||
```nix
|
||||
services.beszel.agent.environment = {
|
||||
EXTRA_FILESYSTEMS = "/mnt/data"; # colon-separated for multiple paths
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: monitoring Docker containers
|
||||
|
||||
`enable-agent.nix` has a commented-out line for Docker monitoring:
|
||||
|
||||
```nix
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
```
|
||||
|
||||
Uncomment it if the host runs docker-socket-proxy and you want per-container
|
||||
stats. Hosts without Docker should leave it commented out.
|
||||
|
||||
---
|
||||
|
||||
## If the hub key changes
|
||||
|
||||
If the docker host is ever rebuilt and beszel generates a new SSH keypair,
|
||||
update `beszelHubKey` in `variables.nix` and rebuild all beszel-enabled hosts.
|
||||
The new key is visible in the beszel hub under **Settings → Keys**.
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# HA File-Server Cluster
|
||||
|
||||
Two `proxmox-ha-server-{1,2}` VMs form an active/passive file-server cluster:
|
||||
DRBD replicates a block device between nodes; Corosync + Pacemaker manage
|
||||
failover; XFS, LIO iSCSI, and NFS are brought up as a collocated resource
|
||||
group on whichever node holds the DRBD Primary role.
|
||||
|
||||
NixOS modules: `modules/ha/`. Lifecycle scripts: `scripts/ha/`.
|
||||
Cluster-wide constants: `variables.nix` (`haServer*` vars).
|
||||
|
||||
---
|
||||
|
||||
## Network layout
|
||||
|
||||
Three subnets — all internal to pve1 (`vmbr0`/`vmbr1`/`vmbr2`):
|
||||
|
||||
| Subnet | VLAN | CIDR | Bridge | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| LAN | 2 | `192.168.2.0/24` | `vmbr0` | Management, LAN NFS |
|
||||
| Cluster | 10 | `192.168.10.224/29` | `vmbr1` | Corosync ring0 + DRBD replication |
|
||||
| Storage-client | 20 | `192.168.20.0/24` | `vmbr2` | NFS + iSCSI for docker/swarm |
|
||||
|
||||
Each HA VM has three NICs: `ens18` (LAN/vmbr0), `ens19` (cluster/vmbr1),
|
||||
`ens20` (storage-client/vmbr2). See `docs/ip-addressing.md` for all IPs.
|
||||
|
||||
Corosync ring0 uses the cluster NIC; ring1 (backup heartbeat) uses the LAN
|
||||
NIC. DRBD replicates over the cluster NIC. No storage traffic crosses the LAN.
|
||||
|
||||
---
|
||||
|
||||
## Pacemaker resources
|
||||
|
||||
All resources run collocated on whichever node is Primary, in this order:
|
||||
|
||||
```
|
||||
ms-drbd0 (promotable DRBD clone)
|
||||
→ xfs-data (XFS mount on /dev/drbd0 → /srv/ha-data)
|
||||
→ iscsi-target (targetctl)
|
||||
→ nfs-server (nfs-server.service)
|
||||
→ vip-lan (192.168.2.229/24 on vmbr0 — NFS for LAN clients)
|
||||
→ vip-storage (192.168.20.229/24 on vmbr2 — NFS + iSCSI for VLAN 20)
|
||||
```
|
||||
|
||||
`vip-lan` serves pxe-boot and other LAN-only NFS clients.
|
||||
`vip-storage` serves docker and any future swarm nodes; iSCSI is available on
|
||||
VLAN 20 but NFS is preferred for multi-host volume sharing.
|
||||
|
||||
---
|
||||
|
||||
## DRBD fencing
|
||||
|
||||
`fencing resource-only` with `crm-fence-peer.sh`/`crm-unfence-peer.sh`
|
||||
wrappers (`modules/ha/cluster-config.nix`). The DRBD kernel module invokes
|
||||
these via the User Mode Helper with a minimal PATH; the wrappers prepend
|
||||
`/run/current-system/sw/bin` before exec-ing the real handlers so Pacemaker
|
||||
tools (`cibadmin`, `crm_mon`, etc.) are found.
|
||||
|
||||
STONITH is initially disabled (`stonith-enabled: false`,
|
||||
`no-quorum-policy: ignore`). Enable it once the `fence_pve_ssh` fence agent
|
||||
(`scripts/ha/fence-pve-ssh.py`) is deployed and authorised:
|
||||
|
||||
```bash
|
||||
scripts/ha/cluster-enable-stonith.sh # run as root on ha-server-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploying the cluster from scratch
|
||||
|
||||
Use `scripts/ha/deploy.sh` — it orchestrates all phases:
|
||||
|
||||
```bash
|
||||
# Against pve-test (safe — Claude's default target):
|
||||
scripts/ha/deploy.sh --node "$PVE_TEST_HOST" [--dry-run]
|
||||
|
||||
# Against pve1 (production — requires explicit operator go-ahead):
|
||||
scripts/ha/deploy.sh --node "$PVE1_HOST"
|
||||
```
|
||||
|
||||
Phases (each skippable with `--skip-<phase>`):
|
||||
1. `ensure-bridge` — creates `vmbr1`/`vmbr2` on the Proxmox node if absent
|
||||
2. `sync-keys` — generates SSH host keys for both nodes; registers sops recipients
|
||||
3. `create-vms` — builds disk images, creates VMs via `create-proxmox-resource.sh`
|
||||
4. `add-hardware` — attaches storage NIC and DRBD data disk to each VM
|
||||
5. `init-cluster` — runs `scripts/ha/cluster-init.sh` on ha-server-1
|
||||
|
||||
`--destroy` runs the teardown sequence.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day operations
|
||||
|
||||
```bash
|
||||
# Read-only health check (safe from workstation):
|
||||
scripts/ha/health.sh
|
||||
|
||||
# Graceful failover (prompts for confirmation):
|
||||
scripts/ha/failover.sh [--to node1|node2]
|
||||
|
||||
# Online data-disk growth (no downtime):
|
||||
scripts/ha/resize-data-disk.sh --size +20G
|
||||
|
||||
# Acceptance tests (run after any significant change):
|
||||
scripts/ha/acceptance-tests.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding FreeIPA host accounts
|
||||
|
||||
IPA host registration is automated:
|
||||
|
||||
```bash
|
||||
scripts/ipa/create-nixos-ipa-host-account.sh <hostname>
|
||||
```
|
||||
|
||||
This runs `ipa host-add`, fetches a keytab from the domain controller, and
|
||||
writes a sops-encrypted `secrets/<hostname>.keytab` in one step. The module
|
||||
`modules/ipa/client.nix` (imported by every host via
|
||||
`modules/common/configuration.nix`) consumes the keytab via sops-nix.
|
||||
|
||||
---
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
/srv/ha-data/
|
||||
docker/
|
||||
config/ NFS → docker:/mnt/docker/config
|
||||
databases/ NFS → docker:/mnt/docker/databases
|
||||
volumes/ NFS → docker:/mnt/docker/volumes
|
||||
nextcloud-data/ NFS → docker:/mnt/docker/nextcloud-data
|
||||
proxmox/
|
||||
iso/ NFS → pve1 ISO storage
|
||||
lxc/ NFS → pve1 CT template storage
|
||||
pxe-boot/
|
||||
images/ NFS → pxe-boot:/srv/pxe/http/images (PXE assets)
|
||||
raspi/
|
||||
volumes/ NFS → raspi NFS mounts
|
||||
iscsi-lun.img iSCSI fileio backstore (VLAN 20 only, not in active use)
|
||||
```
|
||||
|
||||
All shares are defined in `variables.nix` (`vars.nfsShares.*`). The NFS
|
||||
export list lives in `modules/ha/nfs-exports.nix`.
|
||||
|
||||
---
|
||||
|
||||
## Key variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `vars.haServer1Ip` / `vars.haServer2Ip` | LAN management IPs |
|
||||
| `vars.haServer1StorageIp` / `vars.haServer2StorageIp` | Cluster NIC IPs (DRBD/Corosync ring0) |
|
||||
| `vars.haServerLanVip` | Pacemaker `vip-lan` — NFS for LAN (192.168.2.229) |
|
||||
| `vars.haServerVip` | Pacemaker `vip-storage` — NFS + iSCSI for VLAN 20 (192.168.20.229) |
|
||||
| `vars.haLanNfsFqdn` | FQDN of `vip-lan`: `ha-vip-lan.sweet.home` |
|
||||
| `vars.haStorageRoot` | XFS mount point: `/srv/ha-data` |
|
||||
| `vars.haServerDrbdDisk` | Block device for DRBD backing store |
|
||||
| `vars.haStorageCidr` | Cluster subnet CIDR (`192.168.10.224/29`) |
|
||||
| `vars.haClientCidr` | Storage-client subnet CIDR (`192.168.20.0/24`) |
|
||||
@@ -0,0 +1,330 @@
|
||||
# Docker Swarm Cutover Plan
|
||||
|
||||
Migration guide for moving containerised services from the existing single-host
|
||||
Docker LXC container (CT 105, `docker.sweet.home`, 192.168.2.225) to the new
|
||||
Docker Swarm cluster (`ha-docker-1` / `ha-docker-2`, 192.168.2.230–231).
|
||||
|
||||
CT 105 stays running throughout. Services migrate one stack at a time.
|
||||
Roll back any stack by restarting it on CT 105 if anything goes wrong.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Swarm cluster deployed and healthy (`scripts/docker-swarm/deploy.sh`).
|
||||
- Both nodes show `Ready / Active / Manager` in `docker node ls`.
|
||||
- NFS mounts healthy on both swarm nodes (`/mnt/docker/config`, `/mnt/docker/databases`, `/mnt/docker/volumes`).
|
||||
- Access to FreeIPA DNS admin to update A records during cutover.
|
||||
|
||||
---
|
||||
|
||||
## 1. Traefik — switch to Docker log rotation
|
||||
|
||||
**Current state (CT 105):** Traefik writes access logs to the NFS volume at
|
||||
`/mnt/docker/volumes/traefik-data/logs/`. `modules/traefik/rotate-logs.nix`
|
||||
rotates those files via `logrotate`.
|
||||
|
||||
**Swarm approach:** Remove file-based access logging from Traefik's static
|
||||
config and rely on Docker's json-file log driver with built-in rotation.
|
||||
Traefik container logs (including access events) then live under
|
||||
`/var/lib/docker/containers/<id>/` on the node running Traefik.
|
||||
|
||||
### Steps
|
||||
|
||||
**1a.** In the Traefik stack definition, add logging config to the service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "20"
|
||||
```
|
||||
|
||||
**1b.** In `traefik.yml` (Traefik's static config), remove the `accessLog`
|
||||
file path if present. To keep structured access logs, use Traefik's
|
||||
`accessLog.format: json` with no `filePath` — logs then go to stdout and are
|
||||
captured by the json-file driver above.
|
||||
|
||||
**1c.** Deploy Traefik to the swarm:
|
||||
|
||||
```bash
|
||||
# On either swarm manager:
|
||||
docker stack deploy -c /mnt/docker/config/traefik/docker-compose.yml traefik
|
||||
```
|
||||
|
||||
Traefik should be deployed as a **global mode** service so it runs on all
|
||||
swarm nodes and handles ingress on whichever node a request arrives at:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
deploy:
|
||||
mode: global
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == manager
|
||||
```
|
||||
|
||||
**1d.** After confirming Traefik works on the swarm, remove
|
||||
`traefik/rotate-logs.nix` from the `docker` build type in
|
||||
`modules/build-types/docker.nix` and rebuild CT 105.
|
||||
|
||||
**DNS:** Update `docker.sweet.home` and any service FQDNs that point at
|
||||
192.168.2.225 to a swarm VIP or round-robin A records once Traefik is running
|
||||
on the swarm. See section 8 (DNS cutover).
|
||||
|
||||
---
|
||||
|
||||
## 2. Nextcloud — migrate cron job to sidecar container
|
||||
|
||||
**Current state (CT 105):** `modules/docker/nextcloud-cron-job.nix` runs a
|
||||
systemd timer every 5 minutes that calls:
|
||||
```bash
|
||||
docker exec nextcloud-webapp php ./cron.php
|
||||
```
|
||||
|
||||
**Swarm problem:** `docker exec` only works against the local daemon. If
|
||||
Nextcloud is scheduled on the other swarm node, the exec fails silently and
|
||||
cron never runs.
|
||||
|
||||
**Swarm approach:** Add a `nextcloud-cron` sidecar container to the Nextcloud
|
||||
stack definition, pinned to the same node as the main Nextcloud container via
|
||||
placement constraints.
|
||||
|
||||
### Steps
|
||||
|
||||
**2a.** Choose which swarm node will host Nextcloud (e.g. `ha-docker-1`).
|
||||
Label that node:
|
||||
|
||||
```bash
|
||||
# On either swarm manager:
|
||||
docker node update --label-add nextcloud=true ha-docker-1
|
||||
```
|
||||
|
||||
**2b.** In the Nextcloud stack compose file, add the sidecar and pin both
|
||||
services to the labelled node:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nextcloud-webapp:
|
||||
image: nextcloud:production # pin same version as CT 105
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.nextcloud == true
|
||||
# ... existing volumes, env, networks ...
|
||||
|
||||
nextcloud-cron:
|
||||
image: nextcloud:production # same image, different entrypoint
|
||||
entrypoint: /cron.sh
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.nextcloud == true # must co-locate with webapp
|
||||
volumes:
|
||||
# Same data volume as nextcloud-webapp so cron sees the same files.
|
||||
- nextcloud-data:/var/www/html
|
||||
# No ports exposed — cron only runs PHP inside the container.
|
||||
```
|
||||
|
||||
`/cron.sh` is Nextcloud's built-in cron entrypoint. It runs
|
||||
`php -f /var/www/html/cron.php` in a loop, sleeping for 5 minutes between
|
||||
runs — identical to the current systemd timer.
|
||||
|
||||
**2c.** Migrate Nextcloud's data volume to the swarm:
|
||||
|
||||
```
|
||||
/mnt/docker/volumes/nextcloud-data/ → already on NFS, no migration needed
|
||||
/mnt/docker/databases/nextcloud/ → already on NFS, no migration needed
|
||||
```
|
||||
|
||||
The NFS paths are identical on the swarm nodes (`mount-data.nix` mounts the
|
||||
same shares from the same VIP). Stop Nextcloud on CT 105, deploy on the
|
||||
swarm, confirm it starts cleanly.
|
||||
|
||||
**2d.** Remove `nextcloud-cron-job.nix` from `modules/build-types/docker.nix`
|
||||
and rebuild CT 105 after confirming Nextcloud works on the swarm.
|
||||
|
||||
---
|
||||
|
||||
## 3. docker-health-to-gotify — update for swarm awareness
|
||||
|
||||
**Current state (CT 105):** The script at
|
||||
`/home/nixos/docker/monitoring/gotify/docker-health-to-gotify.sh` runs every
|
||||
minute, calls `docker ps --filter health=unhealthy`, and notifies Gotify.
|
||||
|
||||
**Swarm behaviour:** The same script runs on both swarm nodes independently,
|
||||
each monitoring its own local Docker daemon. This gives per-node coverage
|
||||
across the swarm.
|
||||
|
||||
**Changes needed in the script** (edit the copy on the NFS volume — it takes
|
||||
effect on both nodes simultaneously on the next timer fire):
|
||||
|
||||
### 3a. Strip the Swarm task suffix from service names
|
||||
|
||||
In swarm mode, `docker ps --format '{{.Names}}'` returns names like
|
||||
`nextcloud-webapp.1.abc123xyz`. The notification should show `nextcloud-webapp`,
|
||||
not the full task name.
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
CONTAINER_NAME=$(docker ps --format '{{.Names}}' ...)
|
||||
|
||||
# After:
|
||||
CONTAINER_NAME=$(docker ps --format '{{.Names}}' ... | cut -d. -f1)
|
||||
```
|
||||
|
||||
### 3b. Include the reporting node in the Gotify message
|
||||
|
||||
Add `$(hostname)` to the notification payload so you know which swarm node
|
||||
detected the problem:
|
||||
|
||||
```bash
|
||||
MESSAGE="[$(hostname)] ${CONTAINER_NAME} is unhealthy"
|
||||
```
|
||||
|
||||
### 3c. Extend to catch swarm service replica failures
|
||||
|
||||
`docker ps` only shows what's running locally. If a service has zero healthy
|
||||
replicas (task crash-looping) it may not show up on either node's `docker ps`
|
||||
at the same moment. Add a swarm-level check:
|
||||
|
||||
```bash
|
||||
# Run only on managers (both ha-docker nodes are managers):
|
||||
if docker info --format '{{.Swarm.ControlAvailable}}' 2>/dev/null | grep -q true; then
|
||||
# Find services where running replicas < desired replicas
|
||||
docker service ls --format '{{.Name}}\t{{.Replicas}}' | \
|
||||
awk -F'\t' '$2 !~ /^[0-9]+\/[0-9]+$/ || split($2,a,"/") && a[1] < a[2] { print $1, $2 }' | \
|
||||
while read -r svc_name replicas; do
|
||||
# Send Gotify notification for degraded service
|
||||
curl -s -X POST "${GOTIFY_URL}/message" \
|
||||
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
|
||||
-d "title=Swarm service degraded" \
|
||||
-d "message=[$(hostname)] ${svc_name}: ${replicas} replicas"
|
||||
done
|
||||
fi
|
||||
```
|
||||
|
||||
This catches the case where a service's desired replicas are not running
|
||||
(e.g. OOM kill, image pull failure) — a failure mode that doesn't produce a
|
||||
Docker health event on any node.
|
||||
|
||||
---
|
||||
|
||||
## 4. Passbolt migration
|
||||
|
||||
Passbolt has strict data integrity requirements. Migrate with care:
|
||||
|
||||
1. **Backup first** — `docker exec passbolt-webapp php /usr/share/php/passbolt/bin/cake passbolt export_keys` and a database dump.
|
||||
2. Database is on NFS (`/mnt/docker/databases/passbolt/`) — no data copy needed.
|
||||
3. Pin Passbolt to a specific node: `docker node update --label-add passbolt=true ha-docker-1`
|
||||
4. Add placement constraint `node.labels.passbolt == true` to the Passbolt stack.
|
||||
5. Stop on CT 105, deploy on swarm, verify login works.
|
||||
6. Test email delivery and 2FA.
|
||||
|
||||
---
|
||||
|
||||
## 5. Gitea migration
|
||||
|
||||
Gitea's data directory is on NFS (`/mnt/docker/volumes/gitea-data/`).
|
||||
|
||||
1. Stop Gitea on CT 105: `docker stop gitea`
|
||||
2. Deploy to swarm with placement constraint (pin to `ha-docker-1` initially).
|
||||
3. Verify web UI and SSH clone/push work.
|
||||
4. Update DNS: `gitea.lan.ddnsgeek.com` → swarm Traefik endpoint.
|
||||
5. Update the flake remote URL in `variables.nix` (`giteaDomain`) if the address changes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Other services
|
||||
|
||||
Deploy remaining services (Grafana, InfluxDB, NodeRed, Prometheus, etc.)
|
||||
as swarm stacks. Most have no special migration concern — they use NFS
|
||||
volumes already on the shared storage.
|
||||
|
||||
Services with stateful databases (PostgreSQL, MariaDB) should follow the
|
||||
pattern: stop on CT 105, confirm NFS database directory is intact, deploy on
|
||||
swarm, verify.
|
||||
|
||||
---
|
||||
|
||||
## 7. Monitoring — Beszel
|
||||
|
||||
The Beszel hub runs on CT 105 (`docker.sweet.home:8090`). Both swarm nodes
|
||||
run `beszel-agent` (from `modules/beszel/enable-agent.nix`), pointing at the
|
||||
existing hub URL.
|
||||
|
||||
No migration needed for Beszel itself during the container migration. Once
|
||||
all services are on the swarm, you may wish to move the Beszel hub too (as a
|
||||
swarm service with a placement constraint) but this is optional.
|
||||
|
||||
---
|
||||
|
||||
## 8. DNS cutover
|
||||
|
||||
When a service is confirmed working on the swarm, update the FreeIPA DNS
|
||||
A record from the CT 105 IP (192.168.2.225) to a swarm node IP or, when a
|
||||
shared Traefik frontend is in place, to a round-robin record across both nodes.
|
||||
|
||||
**Recommended approach — Traefik as the single entry point:**
|
||||
|
||||
```
|
||||
service.lan.ddnsgeek.com → Traefik on swarm (global mode)
|
||||
docker.sweet.home → keep as 192.168.2.225 (CT 105) until fully decommissioned
|
||||
```
|
||||
|
||||
For LAN-only services using `*.sweet.home` names, update FreeIPA directly:
|
||||
|
||||
```bash
|
||||
# On domain-controller (or via SSH):
|
||||
ipa dnsrecord-mod sweet.home nextcloud --a-rec=192.168.2.230
|
||||
# Add 192.168.2.231 as a second A record for round-robin (optional):
|
||||
ipa dnsrecord-add sweet.home nextcloud --a-rec=192.168.2.231
|
||||
```
|
||||
|
||||
Services behind Traefik don't need their own DNS updates — only Traefik's
|
||||
own entry point IPs need to change.
|
||||
|
||||
---
|
||||
|
||||
## 9. NixOS cleanup — CT 105
|
||||
|
||||
Once all services are migrated:
|
||||
|
||||
**Remove from `modules/build-types/docker.nix`:**
|
||||
- `../docker/nextcloud-cron-job.nix` — replaced by sidecar container
|
||||
- `../traefik/rotate-logs.nix` — replaced by Docker log driver
|
||||
|
||||
**Keep in `modules/build-types/docker.nix` until CT 105 is decommissioned:**
|
||||
- `../docker/docker-health-to-gotify.nix` — still monitors CT 105's own daemon
|
||||
- Everything else
|
||||
|
||||
**When decommissioning CT 105:**
|
||||
1. Confirm all NFS volumes are in use only by swarm services (not CT 105).
|
||||
2. Stop CT 105: `pct stop 105` on pve1.
|
||||
3. Archive/remove the `lxc-docker` and `proxmox-docker` targets from `flake.nix`.
|
||||
4. Remove `hosts/docker/`, `modules/build-types/docker.nix`, and `modules/docker/`.
|
||||
5. Update `variables.nix` to remove `dockerIp`, `dockerStorageIp`, `dockerHost`
|
||||
(or reassign `dockerHost` to point at a swarm node for Beszel hub resolution).
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Any stack can be rolled back to CT 105 independently:
|
||||
|
||||
```bash
|
||||
# On CT 105:
|
||||
docker start <service-name>
|
||||
# Update DNS A record back to 192.168.2.225
|
||||
ipa dnsrecord-mod sweet.home <service> --a-rec=192.168.2.225
|
||||
```
|
||||
|
||||
CT 105 remains running throughout the cutover. Only decommission it after
|
||||
every service is confirmed stable on the swarm and you have run one full
|
||||
backup cycle from the new hosts.
|
||||
@@ -0,0 +1,228 @@
|
||||
# IP Addressing Scheme
|
||||
|
||||
## Subnets
|
||||
|
||||
| Subnet | VLAN | CIDR | Purpose | Routed? |
|
||||
|---|---|---|---|---|
|
||||
| LAN | 2 (native/untagged) | `192.168.2.0/24` | General LAN — clients and infrastructure | Yes (gateway .254) |
|
||||
| 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 |
|
||||
| Swarm cluster | 30 | `192.168.30.0/24` | Docker Swarm gossip (TCP/UDP 7946) + VXLAN overlay (UDP 4789) | No — internal `vmbr3` only, no uplink |
|
||||
|
||||
When expanded to a second Proxmox node, VLAN 10 (cluster), VLAN 20 (storage-client), and VLAN 30 (swarm) all share
|
||||
the same inter-node trunk NIC via 802.1q VLAN tagging — different VLAN IDs, same physical cable.
|
||||
|
||||
The cluster and storage-client subnets never leave pve1. `vmbr1` and `vmbr2` are Proxmox Linux
|
||||
bridges with no physical port attached; traffic between guests on each bridge stays in-kernel.
|
||||
|
||||
VLAN IDs match the third octet of each subnet (VLAN 2 → 192.168.**2**.x, VLAN 10 → 192.168.**10**.x,
|
||||
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.
|
||||
|
||||
Four zones correspond to the four subnets. 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.
|
||||
|
||||
---
|
||||
|
||||
## LAN — 192.168.2.0/24
|
||||
|
||||
### Address map
|
||||
|
||||
| Range | Purpose |
|
||||
|---|---|
|
||||
| .1–.9 | Reserved, never assign |
|
||||
| .10–.59 | Client DHCP pool (router-assigned) |
|
||||
| .60–.219 | Unallocated buffer |
|
||||
| .220–.229 | Virtual nodes (VMs / LXC containers) |
|
||||
| .230–.239 | Expansion buffer (reserved, unallocated) |
|
||||
| .240–.249 | Physical nodes (bare-metal hosts) |
|
||||
| .250–.253 | Network services |
|
||||
| .254 | Router / gateway |
|
||||
|
||||
### Network services (.250–.253)
|
||||
|
||||
| IP | Hostname | Role |
|
||||
|---|---|---|
|
||||
| `192.168.2.254` | router | Gateway (TP-Link) |
|
||||
| `192.168.2.253` | domain-controller | FreeIPA — authoritative DNS for `sweet.home`, Kerberos, LDAP |
|
||||
| `192.168.2.250`–`.252` | — | Reserved for future network services |
|
||||
|
||||
### Physical nodes (.240–.249)
|
||||
|
||||
| IP | Hostname | Role |
|
||||
|---|---|---|
|
||||
| `192.168.2.245` | pve1 | Proxmox VE hypervisor |
|
||||
| `192.168.2.244` | pbs | Proxmox Backup Server |
|
||||
| `192.168.2.243` | nixos | Bare-metal workstation (`baremetal-gui`) |
|
||||
| `192.168.2.246`–`.249` | — | Reserved — second Proxmox node and associated services |
|
||||
| `192.168.2.240`–`.242` | — | Reserved |
|
||||
|
||||
pve1 sits mid-range deliberately so a second Proxmox node can slot in on either side.
|
||||
|
||||
### Virtual nodes (.220–.229)
|
||||
|
||||
All VMs and LXC containers run on pve1.
|
||||
|
||||
| IP | Hostname | Role | Status |
|
||||
|---|---|---|---|
|
||||
| `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 — management NIC | Active |
|
||||
| `192.168.2.227` | ha-node2 | HA file server node 2 — management NIC | Active |
|
||||
| `192.168.2.226` | server | Former NFS/ZFS file server — decommissioned | Removed from flake |
|
||||
| `192.168.2.225` | docker | Docker / Traefik stack (CT 105 — existing single-host) | 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.222` | tailscale-router | Tailscale exit node / router | Active |
|
||||
| `192.168.2.221` | tor-relay | Tor relay | Active |
|
||||
| `192.168.2.220` | pdm | Proxmox Deploy Manager | Active |
|
||||
| `192.168.2.231` | ha-docker-2 | Docker Swarm node 2 — management NIC | Active |
|
||||
| `192.168.2.230` | ha-docker-1 | Docker Swarm node 1 — management NIC | Active |
|
||||
|
||||
### Client DHCP pool (.10–.59)
|
||||
|
||||
Assigned by the router. DNS option points to `192.168.2.253` (domain-controller).
|
||||
|
||||
Devices in this range: phones, laptops, IoT, Canon printer, any non-infrastructure host.
|
||||
No static reservations for infrastructure hosts — all infra uses static IP configuration
|
||||
on the guest itself (not DHCP reservations), so IPs survive VM recreation regardless of
|
||||
MAC address churn.
|
||||
|
||||
---
|
||||
|
||||
## Cluster network — VLAN 10 — 192.168.10.224/29
|
||||
|
||||
Internal to pve1 only. Proxmox bridge `vmbr1`, no physical NIC attached.
|
||||
|
||||
| IP | Hostname | Interface role |
|
||||
|---|---|---|
|
||||
| `192.168.10.228` | ha-node1 | DRBD replication + Corosync ring0 (primary heartbeat) |
|
||||
| `192.168.10.227` | ha-node2 | DRBD replication + Corosync ring0 (primary heartbeat) |
|
||||
| — | 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 (CT 105) |
|
||||
| `192.168.20.231` | ha-docker-2 | Storage-client NIC (ens19 / vmbr2) — NFS client |
|
||||
| `192.168.20.230` | ha-docker-1 | Storage-client NIC (ens19 / 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.
|
||||
|
||||
---
|
||||
|
||||
## Swarm cluster network — VLAN 30 — 192.168.30.0/24
|
||||
|
||||
Internal to pve1 only. Proxmox bridge `vmbr3`, no physical NIC attached.
|
||||
Carries Docker Swarm inter-node traffic only: Raft consensus (TCP 2377),
|
||||
Serf gossip (TCP/UDP 7946), and VXLAN overlay data path (UDP 4789).
|
||||
Docker Swarm is initialised with `--advertise-addr` and `--data-path-addr`
|
||||
both pointing to this subnet so all cluster traffic stays on `vmbr3` and
|
||||
never crosses the LAN.
|
||||
|
||||
| IP | Hostname | Interface / role |
|
||||
|---|---|---|
|
||||
| `192.168.30.231` | ha-docker-2 | Swarm cluster NIC (ens20 / vmbr3) |
|
||||
| `192.168.30.230` | ha-docker-1 | Swarm cluster NIC (ens20 / vmbr3) |
|
||||
| — | no gateway | Isolated — not routed to LAN or internet |
|
||||
|
||||
### DNS zone: `swarm.home` — VLAN 30 (192.168.30.x)
|
||||
|
||||
| Hostname | A record | Notes |
|
||||
|---|---|---|
|
||||
| `ha-docker-1.swarm.home` | `192.168.30.230` | Swarm NIC — debugging only |
|
||||
| `ha-docker-2.swarm.home` | `192.168.30.231` | Swarm NIC — debugging only |
|
||||
|
||||
Operators reach the Docker API on the LAN IPs (`192.168.2.230`/`.231`), not these addresses.
|
||||
The `swarm.home` records exist for diagnostic convenience (e.g. confirming `vmbr3` routing).
|
||||
|
||||
### Multi-node Proxmox expansion
|
||||
|
||||
When a second Proxmox node (pve2) is added, VLAN 10 (cluster), VLAN 20 (storage-client),
|
||||
and VLAN 30 (swarm) all extend to pve2 via 802.1q VLAN tagging on the inter-node trunk
|
||||
link. All three internal networks share the same physical NIC between hypervisors —
|
||||
VLAN tags provide the logical separation.
|
||||
|
||||
+97
-7
@@ -15,6 +15,7 @@ rescue/inspection use.
|
||||
- TFTP root for first-stage bootloaders: `/srv/pxe/tftp`
|
||||
- iPXE entry script: `/srv/pxe/http/boot.ipxe`
|
||||
- Generated iPXE menu: `/srv/pxe/http/menu.ipxe`
|
||||
- Debian Minimal iPXE script: `/srv/pxe/http/debian.ipxe`
|
||||
- SystemRescue iPXE script: `/srv/pxe/http/systemrescue.ipxe`
|
||||
- TFTP fallback script: `/srv/pxe/tftp/autoexec.ipxe`
|
||||
- Boot binaries copied from the Nix `ipxe` package:
|
||||
@@ -28,32 +29,51 @@ The host creates these directories with systemd tmpfiles:
|
||||
```text
|
||||
/srv/pxe
|
||||
/srv/pxe/http
|
||||
/srv/pxe/http/images
|
||||
/srv/pxe/http/images -> /mnt/pxe-images (symlink to NFS share)
|
||||
/srv/pxe/http/auto-installer
|
||||
/srv/pxe/http/nixos-minimal
|
||||
/srv/pxe/http/debian
|
||||
/srv/pxe/http/systemrescue
|
||||
/srv/pxe/http/ubuntu
|
||||
/srv/pxe/http/rescue
|
||||
/srv/pxe/tftp
|
||||
```
|
||||
|
||||
Mount shared image storage under `/srv/pxe/http`, preferably
|
||||
`/srv/pxe/http/images` unless a menu entry expects files in a specific
|
||||
directory such as `/srv/pxe/http/auto-installer`.
|
||||
`/srv/pxe/http/images` is a symlink to `/mnt/pxe-images`, which is an NFS
|
||||
mount of `server.sweet.home:/tank/pxe-boot/images`
|
||||
(`modules/pxe-boot/mount-pxe-images.nix`). Place large images there (ISOs,
|
||||
disk images) rather than on the pxe-boot host's own root disk. For an LXC
|
||||
pxe-boot container the mount uses NFSv3+nolock with `nofail` (eager,
|
||||
non-blocking on server unavailability); for a Proxmox VM it uses NFSv4.2
|
||||
with `x-systemd.automount` (lazy, triggered on first access).
|
||||
|
||||
When running as `lxc-pxe-boot`, the Proxmox container must have
|
||||
`features: nesting=1,mount=nfs` (at minimum) in its Proxmox config. `nesting=1`
|
||||
is required by systemd 260+ for credential isolation (user namespace creation
|
||||
and internal move-mounts); without it, AppArmor denies both, and every
|
||||
systemd service that uses `PrivateUsers`, `PrivateDevices`, or credential
|
||||
passing fails on boot. `mount=nfs` allows the NFSv3 mount. Both are set
|
||||
automatically by `scripts/proxmox/create-proxmox-resource.sh` (via
|
||||
`PROXMOX_DEFAULT_LXC_FEATURES` in `scripts/env.sh` which defaults to
|
||||
`nesting=1,keyctl=1,mount=nfs;nfs4`). If you ever change these features
|
||||
manually via `pct set`, be sure to include both — `pct set` replaces the
|
||||
entire features string, it does not append to it.
|
||||
|
||||
The HTTP iPXE chain is:
|
||||
|
||||
```text
|
||||
undionly.kpxe or ipxe.efi
|
||||
-> autoexec.ipxe from the TFTP root, when iPXE requests it
|
||||
-> http://192.168.2.247/boot.ipxe
|
||||
-> http://192.168.2.247/menu.ipxe
|
||||
-> http://192.168.2.223/boot.ipxe
|
||||
-> http://192.168.2.223/menu.ipxe
|
||||
```
|
||||
|
||||
The generated menu currently exposes entries for:
|
||||
|
||||
- NixOS Auto-Installer
|
||||
- NixOS Minimal
|
||||
- Debian Minimal
|
||||
- FreeIPA Server (Rocky Linux 9)
|
||||
- SystemRescue environment
|
||||
- iPXE shell
|
||||
- Reboot
|
||||
@@ -84,19 +104,82 @@ directory name (`auto-installer` / `nixos-minimal`), so each one's
|
||||
generated system name (`nixos-system-<name>-*`) is self-describing rather
|
||||
than the nixpkgs default of `nixos-system-nixos-*` for both.
|
||||
|
||||
The Debian Minimal entry chains `http://<pxeServerIp>/debian.ipxe`, which loads
|
||||
the Debian bookworm netboot kernel and initrd from `/srv/pxe/http/debian/`. The
|
||||
`fetch-debian-netboot.service` oneshot downloads these files from
|
||||
`deb.debian.org` on first boot (idempotent — skips if files are already
|
||||
present):
|
||||
|
||||
```text
|
||||
/srv/pxe/http/debian/linux (Debian bookworm netboot kernel)
|
||||
/srv/pxe/http/debian/initrd.gz (Debian bookworm netboot initrd)
|
||||
```
|
||||
|
||||
The service requires outbound internet access on the pxe-boot host. To
|
||||
re-download (e.g. after a Debian point release), delete the files and restart
|
||||
the service:
|
||||
|
||||
```bash
|
||||
rm /srv/pxe/http/debian/linux /srv/pxe/http/debian/initrd.gz
|
||||
systemctl restart fetch-debian-netboot.service
|
||||
```
|
||||
|
||||
To update to a different Debian release, change `debianRelease` in
|
||||
`modules/build-types/pxe-boot.nix` and redeploy.
|
||||
|
||||
The **FreeIPA Server (Rocky Linux 9)** entry chains
|
||||
`http://<pxeServerIp>/rocky-freeipa.ipxe`, which boots the Rocky Linux 9
|
||||
Anaconda installer with a Kickstart file (`rocky-freeipa.ks`) hosted on the
|
||||
same server. The `fetch-rocky-pxeboot.service` oneshot downloads the pxeboot
|
||||
kernel and initrd from the Rocky Linux mirror on first boot (idempotent):
|
||||
|
||||
```text
|
||||
/srv/pxe/http/rocky/vmlinuz (Rocky Linux 9 Anaconda pxeboot kernel)
|
||||
/srv/pxe/http/rocky/initrd.img (Rocky Linux 9 Anaconda pxeboot initrd)
|
||||
```
|
||||
|
||||
The Kickstart file is generated from the NixOS module and staged at
|
||||
`/srv/pxe/http/rocky-freeipa.ks`. It performs a fully unattended install:
|
||||
|
||||
1. Installs Rocky Linux 9 with `ipa-server` + `ipa-server-dns` packages
|
||||
2. Configures static IP `192.168.2.138`, hostname `domain-controller.sweet.home`
|
||||
3. Creates user `wayne` with the `adminSshKey` from `variables.nix`
|
||||
4. Generates random IPA passwords and writes them to `/root/ipa-credentials.txt`
|
||||
5. Creates a `freeipa-first-boot.service` oneshot that runs `ipa-server-install`
|
||||
on first reboot (~20 minutes)
|
||||
|
||||
After the install completes:
|
||||
- SSH in as `wayne@domain-controller` using the admin key
|
||||
- Monitor FreeIPA install progress: `sudo tail -f /root/freeipa-install.log`
|
||||
- Retrieve credentials: `sudo cat /root/ipa-credentials.txt` (save to password manager)
|
||||
- Configure Pi-hole: `server=/sweet.home/192.168.2.138` in dnsmasq
|
||||
|
||||
To refresh the pxeboot files (e.g. after a Rocky point release):
|
||||
|
||||
```bash
|
||||
rm /srv/pxe/http/rocky/vmlinuz /srv/pxe/http/rocky/initrd.img
|
||||
systemctl restart fetch-rocky-pxeboot.service
|
||||
```
|
||||
|
||||
To update to a different Rocky release, change `rockyRelease` in
|
||||
`modules/build-types/pxe-boot.nix` and redeploy.
|
||||
|
||||
The SystemRescue entry expects the source ISO at:
|
||||
|
||||
```text
|
||||
/srv/pxe/http/images/systemrescue.iso
|
||||
```
|
||||
|
||||
Since `/srv/pxe/http/images` is the NFS-backed symlink, place the ISO on the
|
||||
NFS share at `server.sweet.home:/tank/pxe-boot/images/systemrescue.iso`.
|
||||
|
||||
The `stage-systemrescue.service` oneshot extracts that ISO into:
|
||||
|
||||
```text
|
||||
/srv/pxe/http/systemrescue
|
||||
```
|
||||
|
||||
The rescue menu entry then chains `http://192.168.2.247/systemrescue.ipxe`,
|
||||
The rescue menu entry then chains `http://192.168.2.223/systemrescue.ipxe`,
|
||||
which loads the SystemRescue kernel and initramfs from the extracted tree and
|
||||
uses `archiso_http_srv` to fetch the squashfs payload over HTTP.
|
||||
|
||||
@@ -113,6 +196,13 @@ After deployment by an operator, basic service checks are:
|
||||
```bash
|
||||
curl http://pxe-boot/boot.ipxe
|
||||
curl http://pxe-boot/menu.ipxe
|
||||
curl http://pxe-boot/debian.ipxe
|
||||
curl -I http://pxe-boot/debian/linux
|
||||
curl -I http://pxe-boot/debian/initrd.gz
|
||||
curl http://pxe-boot/rocky-freeipa.ipxe
|
||||
curl http://pxe-boot/rocky-freeipa.ks
|
||||
curl -I http://pxe-boot/rocky/vmlinuz
|
||||
curl -I http://pxe-boot/rocky/initrd.img
|
||||
curl http://pxe-boot/systemrescue.ipxe
|
||||
curl -I http://pxe-boot/systemrescue/sysresccd/boot/x86_64/vmlinuz
|
||||
curl -I http://pxe-boot/systemrescue/sysresccd/boot/x86_64/sysresccd.img
|
||||
|
||||
Generated
+157
-7
@@ -1,5 +1,62 @@
|
||||
{
|
||||
"nodes": {
|
||||
"clan-core": {
|
||||
"inputs": {
|
||||
"data-mesher": "data-mesher",
|
||||
"disko": [
|
||||
"disko"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-darwin": "nix-darwin",
|
||||
"nix-select": "nix-select",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"sops-nix": [
|
||||
"sops-nix"
|
||||
],
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783497933,
|
||||
"narHash": "sha256-TxmwEews6URFPqOWEHNychtXbFDgLZjbOfEXtvtOm6U=",
|
||||
"rev": "3dc0221ca09033599fe98055e9bbc81bdf32732a",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/3dc0221ca09033599fe98055e9bbc81bdf32732a.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/clan/clan-core/archive/26.05.tar.gz"
|
||||
}
|
||||
},
|
||||
"data-mesher": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"clan-core",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": [
|
||||
"clan-core",
|
||||
"treefmt-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778718524,
|
||||
"narHash": "sha256-pXLoI6Ax0EnUK6r34UM1vibVC7CfTu6j72R2692ZzPs=",
|
||||
"rev": "12c552ad547d87254f33f33bddd1a2cdbeac754d",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/12c552ad547d87254f33f33bddd1a2cdbeac754d.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/clan/data-mesher/archive/main.tar.gz"
|
||||
}
|
||||
},
|
||||
"disko": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
@@ -51,9 +108,30 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
@@ -95,11 +173,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784350909,
|
||||
"narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
|
||||
"lastModified": 1785119570,
|
||||
"narHash": "sha256-Rgs2xKnGLFWQscxUaXX07oyZeuMDOHEbqDOsgliLFGM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
|
||||
"rev": "d4fd24667c8cbef124bb70a20380cab75ec8474d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -109,6 +187,40 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779036909,
|
||||
"narHash": "sha256-zXcwYQGCT6pzinK+1dBB2ekTVtfxGZAapb3Evdcu4fY=",
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "56c666e108467d87d13508936aade6d567f2a501",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-select": {
|
||||
"locked": {
|
||||
"lastModified": 1763303120,
|
||||
"narHash": "sha256-yxcNOha7Cfv2nhVpz9ZXSNKk0R7wt4AiBklJ8D24rVg=",
|
||||
"rev": "3d1e3860bef36857a01a2ddecba7cdb0a14c35a9",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/3d1e3860bef36857a01a2ddecba7cdb0a14c35a9.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/clan/nix-select/archive/main.tar.gz"
|
||||
}
|
||||
},
|
||||
"nixos-conf-editor": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
@@ -147,11 +259,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1784432872,
|
||||
"narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=",
|
||||
"lastModified": 1785133411,
|
||||
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870",
|
||||
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -163,6 +275,7 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"clan-core": "clan-core",
|
||||
"disko": "disko",
|
||||
"home-manager": "home-manager",
|
||||
"nixos-conf-editor": "nixos-conf-editor",
|
||||
@@ -214,6 +327,22 @@
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"ref": "future-26.11",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
@@ -227,6 +356,27 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780220602,
|
||||
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
url = "github:Mic92/sops-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
clan-core = {
|
||||
url = "https://git.clan.lol/clan/clan-core/archive/26.05.tar.gz";
|
||||
# Deduplicate modules: clan-core bundles its own disko and sops-nix
|
||||
# (both imported by nixosModules.clanCore). Without follows, we'd get
|
||||
# two different versions of each, and disko's _module.args.diskoLib
|
||||
# unique option would conflict. With follows, clan-core uses the same
|
||||
# store paths as us, so NixOS deduplicates the imports.
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
disko.follows = "disko";
|
||||
sops-nix.follows = "sops-nix";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nixos-conf-editor, home-manager, sops-nix, ... } @ inputs:
|
||||
@@ -32,15 +45,31 @@
|
||||
# (hostName, hostId, per-machine secrets). Every build type except
|
||||
# nix-cache itself consumes the nix-cache substituter and remote
|
||||
# builder.
|
||||
mkTarget = { platform, buildType, hostPath, homeFile ? ./modules/common/home.nix }:
|
||||
mkTarget = { platform, buildType, hostPath, homeFile ? ./modules/common/home.nix, nameSuffix ? "" }:
|
||||
let
|
||||
flakeTarget = "${platform}-${buildType}";
|
||||
flakeTarget = "${platform}-${buildType}${nameSuffix}";
|
||||
in
|
||||
nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
inputs.disko.nixosModules.disko
|
||||
sops-nix.nixosModules.sops
|
||||
inputs.clan-core.nixosModules.clanCore
|
||||
{
|
||||
# Required clan settings. directory is the flake root (where
|
||||
# vars/ and sops/ directories live); machine.name is the flake
|
||||
# target name (matches what clan vars generate uses as the key
|
||||
# under vars/per-machine/). enableRecommendedDefaults = false
|
||||
# is mandatory: without it, clan unconditionally enables
|
||||
# networking.useNetworkd, adds packages, and tweaks nix settings
|
||||
# -- none of which belong here.
|
||||
clan.core = {
|
||||
settings.directory = self;
|
||||
settings.machine.name = flakeTarget;
|
||||
enableRecommendedDefaults = false;
|
||||
};
|
||||
}
|
||||
./modules/clan/ssh-host-key.nix
|
||||
./modules/common/configuration.nix
|
||||
./modules/platforms/${platform}.nix
|
||||
./modules/build-types/${buildType}.nix
|
||||
@@ -80,9 +109,6 @@
|
||||
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; };
|
||||
|
||||
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; };
|
||||
proxmox-docker = mkTarget { platform = "proxmox"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
||||
@@ -96,11 +122,17 @@
|
||||
proxmox-pxe-boot = mkTarget { platform = "proxmox"; buildType = "pxe-boot"; hostPath = ./hosts/pxe-boot/host.nix; };
|
||||
lxc-pxe-boot = mkTarget { platform = "lxc"; buildType = "pxe-boot"; hostPath = ./hosts/pxe-boot/host.nix; };
|
||||
|
||||
linode-tailscale-exit-node = mkTarget { platform = "linode"; buildType = "tailscale-exit-node"; hostPath = ./hosts/tailscale-exit-node/host.nix; };
|
||||
proxmox-tailscale-exit-node = mkTarget { platform = "proxmox"; buildType = "tailscale-exit-node"; hostPath = ./hosts/tailscale-exit-node/host.nix; };
|
||||
lxc-tailscale-exit-node = mkTarget { platform = "lxc"; buildType = "tailscale-exit-node"; hostPath = ./hosts/tailscale-exit-node/host.nix; };
|
||||
linode-tailscale-router = mkTarget { platform = "linode"; buildType = "tailscale-router"; hostPath = ./hosts/tailscale-router/host.nix; };
|
||||
proxmox-tailscale-router = mkTarget { platform = "proxmox"; buildType = "tailscale-router"; hostPath = ./hosts/tailscale-router/host.nix; };
|
||||
lxc-tailscale-router = mkTarget { platform = "lxc"; buildType = "tailscale-router"; hostPath = ./hosts/tailscale-router/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; nameSuffix = "-1"; };
|
||||
proxmox-ha-server-2 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-2/host.nix; nameSuffix = "-2"; };
|
||||
|
||||
proxmox-ha-docker-1 = mkTarget { platform = "proxmox"; buildType = "ha-docker"; hostPath = ./hosts/ha-docker-1/host.nix; nameSuffix = "-1"; };
|
||||
proxmox-ha-docker-2 = mkTarget { platform = "proxmox"; buildType = "ha-docker"; hostPath = ./hosts/ha-docker-2/host.nix; nameSuffix = "-2"; };
|
||||
};
|
||||
|
||||
# Auto-install environments (migrated from the former nix-auto-installer
|
||||
@@ -171,7 +203,11 @@
|
||||
(modulesPath + "/installer/netboot/netboot-minimal.nix")
|
||||
];
|
||||
})
|
||||
{ networking.hostName = "nixos-minimal"; }
|
||||
{
|
||||
networking.hostName = "nixos-minimal";
|
||||
system.stateVersion = "26.05";
|
||||
boot.zfs.forceImportRoot = false;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
+17
-3
@@ -1,10 +1,24 @@
|
||||
_:
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking.hostName = "docker";
|
||||
networking.hostId = "007f0200";
|
||||
networking = {
|
||||
hostName = "docker";
|
||||
hostId = "007f0200";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.dockerIp; prefixLength = vars.lanPrefixLength; }];
|
||||
${vars.lxcStorageInterface}.ipv4.addresses = [{ address = vars.dockerStorageIp; prefixLength = vars.haClientPrefixLength; }];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
# Only advertise the LAN interface to IPA DNS. Without this, SSSD registers
|
||||
# every Docker bridge (172.x.x.x) as an A record for docker.sweet.home —
|
||||
# the default dyndns.interface = "*" catches them all.
|
||||
security.ipa.dyndns.interface = vars.lxcLanInterface; # eth0
|
||||
|
||||
# Preserved from the pre-refactor `docker` target — stateVersion must never
|
||||
# be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.haDocker1Host;
|
||||
hostId = "a1d0c4e1";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
# ens18 — LAN management NIC (vmbr0, 192.168.2.0/24)
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{
|
||||
address = vars.haDocker1Ip;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
# ens19 — storage-client NIC (vmbr2, 192.168.20.0/24) — NFS from HA cluster
|
||||
${vars.haDockerStorageInterface}.ipv4.addresses = [{
|
||||
address = vars.haDocker1StorageIp;
|
||||
prefixLength = vars.haClientPrefixLength;
|
||||
}];
|
||||
# ens20 — swarm cluster NIC (vmbr3, 192.168.30.0/24) — Docker gossip + VXLAN
|
||||
${vars.haDockerSwarmInterface}.ipv4.addresses = [{
|
||||
address = vars.haDocker1SwarmIp;
|
||||
prefixLength = vars.haDockerSwarmPrefixLength;
|
||||
}];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# Only register the LAN IP with IPA DNS. Without this, sssd dyndns
|
||||
# would also register Docker bridge IPs (172.x.x.x) and the storage/swarm
|
||||
# NIC IPs as A records for ha-docker-1.sweet.home.
|
||||
security.ipa.dyndns.interface = vars.vmLanInterface;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.haDocker2Host;
|
||||
hostId = "a2d0c4e2";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
# ens18 — LAN management NIC (vmbr0, 192.168.2.0/24)
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{
|
||||
address = vars.haDocker2Ip;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
# ens19 — storage-client NIC (vmbr2, 192.168.20.0/24) — NFS from HA cluster
|
||||
${vars.haDockerStorageInterface}.ipv4.addresses = [{
|
||||
address = vars.haDocker2StorageIp;
|
||||
prefixLength = vars.haClientPrefixLength;
|
||||
}];
|
||||
# ens20 — swarm cluster NIC (vmbr3, 192.168.30.0/24) — Docker gossip + VXLAN
|
||||
${vars.haDockerSwarmInterface}.ipv4.addresses = [{
|
||||
address = vars.haDocker2SwarmIp;
|
||||
prefixLength = vars.haDockerSwarmPrefixLength;
|
||||
}];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# Only register the LAN IP with IPA DNS — same reasoning as ha-docker-1.
|
||||
security.ipa.dyndns.interface = vars.vmLanInterface;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.haServer1Host;
|
||||
hostId = "3a4b5c6d";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.haServer1Ip; prefixLength = vars.lanPrefixLength; }];
|
||||
${vars.vmStorageInterface}.ipv4.addresses = [{ address = vars.haServer1StorageIp; prefixLength = vars.haStoragePrefixLength; }];
|
||||
${vars.vmStorageClientInterface}.ipv4.addresses = [{ address = vars.haServer1ClientIp; prefixLength = vars.haClientPrefixLength; }];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.haServer2Host;
|
||||
hostId = "7e8f9a0b";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.haServer2Ip; prefixLength = vars.lanPrefixLength; }];
|
||||
${vars.vmStorageInterface}.ipv4.addresses = [{ address = vars.haServer2StorageIp; prefixLength = vars.haStoragePrefixLength; }];
|
||||
${vars.vmStorageClientInterface}.ipv4.addresses = [{ address = vars.haServer2ClientIp; prefixLength = vars.haClientPrefixLength; }];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "nix-cache";
|
||||
sopsFile = ../../secrets/nix-cache.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking.hostName = vars.nixCacheHost;
|
||||
|
||||
services.beszel.agent.environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
networking = {
|
||||
hostName = vars.nixCacheHost;
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.nixCacheIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# Preserved from the pre-refactor `nix-cache` target — stateVersion must
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
# vscode
|
||||
chromium
|
||||
claude-code
|
||||
fish
|
||||
sops
|
||||
];
|
||||
|
||||
# Optional: set environment vars
|
||||
sessionVariables = {
|
||||
EDITOR = "vim";
|
||||
SOPS_AGE_KEY_FILE = "/home/nixos/Nextcloud/Filing Cabinet/keys/nixos-sops-age-key-txt";
|
||||
EDITOR = "nano";
|
||||
SOPS_AGE_KEY_FILE = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
|
||||
};
|
||||
|
||||
file = {
|
||||
|
||||
+12
-3
@@ -1,8 +1,17 @@
|
||||
_:
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking.hostName = "pxe-boot";
|
||||
|
||||
networking = {
|
||||
hostName = "pxe-boot";
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.pxeServerIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
services.beszel.agent.environment = { };
|
||||
# Preserved from the pre-refactor `pxe-boot` target — stateVersion must
|
||||
# never be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "server";
|
||||
sopsFile = ../../secrets/server.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking.hostName = vars.nfsServerHost;
|
||||
networking.hostId = "6689f93e";
|
||||
|
||||
services.beszel.agent.environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
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";
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
_:
|
||||
|
||||
{
|
||||
networking.hostName = "exit-node";
|
||||
|
||||
# No networking.hostId: only ZFS-touching hosts (server, docker) need one
|
||||
# for pool-import safety, and this host does neither.
|
||||
|
||||
# A genuinely new host (not a pre-refactor carry-over), so it tracks the
|
||||
# flake's current nixpkgs release rather than being pinned to an older one.
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = "tailscale-router";
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.tailscaleRouterIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# No networking.hostId: only ZFS-touching hosts (server, docker) need one
|
||||
# for pool-import safety, and this host does neither.
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
_:
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking.hostName = "tor-relay";
|
||||
networking = {
|
||||
hostName = "tor-relay";
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.torRelayIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# No networking.hostId: only ZFS-touching hosts (server, docker) need one
|
||||
# for pool-import safety, and this host does neither.
|
||||
# No networking.hostId: only ZFS-touching hosts need one for pool-import
|
||||
# safety, and this host does neither.
|
||||
|
||||
# A genuinely new host (not a pre-refactor carry-over), so it tracks the
|
||||
# flake's current nixpkgs release rather than being pinned to an older one.
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
{ vars, ... }:
|
||||
{ config, vars, ... }:
|
||||
|
||||
{
|
||||
services.beszel.agent.enable = true;
|
||||
services.beszel.agent.environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
HUB_URL = "http://${vars.dockerHost}.${vars.homeDomain}:${toString vars.ports.beszelHub}";
|
||||
# Universal token shared by all beszel agents. Add to secrets/common.yaml:
|
||||
# sops secrets/common.yaml
|
||||
# beszel-token: <value from the beszel hub UI>
|
||||
sops.secrets."beszel-token" = { };
|
||||
|
||||
sops.templates."beszel.env".content = ''
|
||||
TOKEN=${config.sops.placeholder."beszel-token"}
|
||||
'';
|
||||
|
||||
services.beszel.agent = {
|
||||
enable = true;
|
||||
environmentFile = config.sops.templates."beszel.env".path;
|
||||
environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
HUB_URL = "http://${vars.dockerHost}.${vars.homeDomain}:${toString vars.ports.beszelHub}";
|
||||
KEY = vars.beszelHubKey;
|
||||
};
|
||||
};
|
||||
|
||||
# The upstream module runs beszel-agent under DynamicUser with
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{ name, sopsFile }:
|
||||
|
||||
{ config, ... }:
|
||||
|
||||
{
|
||||
sops.secrets."beszel-token".sopsFile = sopsFile;
|
||||
sops.templates."${name}-beszel.env".content = ''
|
||||
TOKEN=${config.sops.placeholder."beszel-token"}
|
||||
'';
|
||||
services.beszel.agent.environmentFile = config.sops.templates."${name}-beszel.env".path;
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
../docker/enable-service.nix
|
||||
../docker/nextcloud-cron-job.nix
|
||||
../docker/docker-health-to-gotify.nix
|
||||
../tailscale/enable-service.nix
|
||||
../traefik/rotate-logs.nix
|
||||
../raspi/mount-data.nix
|
||||
../services/enable-rpcbind.nix
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{ config, pkgs, lib, inputs, vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../docker/enable-service.nix
|
||||
];
|
||||
|
||||
nixpkgs.overlays = [
|
||||
(final: prev: {
|
||||
docker = prev.docker_29;
|
||||
docker_cli = prev.docker_29;
|
||||
})
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
inputs.nixos-conf-editor.packages.${pkgs.stdenv.hostPlatform.system}.nixos-conf-editor
|
||||
nodejs
|
||||
@@ -18,7 +29,7 @@
|
||||
];
|
||||
|
||||
boot.loader.grub.useOSProber = true;
|
||||
programs.direnv.enable = true;
|
||||
programs.direnv.enable = true;
|
||||
services = {
|
||||
xserver = {
|
||||
enable = true;
|
||||
@@ -70,4 +81,70 @@ programs.direnv.enable = true;
|
||||
programs.firefox.enable = true;
|
||||
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
# GUI-specific Home Manager additions for the IPA primary user, extending
|
||||
# the baseline in modules/ipa/client.nix with desktop apps and services
|
||||
# that only make sense on a graphical workstation.
|
||||
home-manager.users.${vars.ipaUser} = { pkgs, ... }: {
|
||||
home = {
|
||||
packages = with pkgs; [
|
||||
git
|
||||
vim
|
||||
nextcloud-client
|
||||
chromium
|
||||
claude-code
|
||||
fish
|
||||
sops
|
||||
];
|
||||
sessionVariables = {
|
||||
EDITOR = "nano";
|
||||
SOPS_AGE_KEY_FILE = "/home/${vars.ipaUser}/.config/sops/age/keys.txt";
|
||||
};
|
||||
file = {
|
||||
".local/share/applications/proxmox-chromium-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox (Chromium)
|
||||
Exec=chromium --app=https://pve.${vars.homeDomain}:${toString vars.ports.pveWeb} --window-size=1920,1080 --window-position=0,0
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=Hypervisor;
|
||||
StartupWMClass=PVE
|
||||
'';
|
||||
".local/share/applications/pbs-chromium-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox Backup Server (Chromium)
|
||||
Exec=chromium --app=https://${vars.pbsIp}:${toString vars.ports.pbsWeb} --window-size=1920,1080 --window-position=0,0
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=backup;
|
||||
'';
|
||||
".local/share/applications/proxmox-firefox-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox (Firefox)
|
||||
Exec=firefox --new-instance https://pve.${vars.homeDomain}:${toString vars.ports.pveWeb} --profile ProxmoxWebApp --window-size=1920,1080 --class ProxmoxWebApp
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=Hypervisor;
|
||||
StartupWMClass=PVE
|
||||
'';
|
||||
".local/share/applications/pbs-firefox-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox Backup Server (Firefox)
|
||||
Exec=firefox --new-window https://${vars.pbsIp}:${toString vars.ports.pbsWeb} --profile PbsWebApp --window-size=1920,1080 --class PbsWebApp
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=backup;
|
||||
StartupWMClass=PBS
|
||||
'';
|
||||
};
|
||||
};
|
||||
services.nextcloud-client = {
|
||||
enable = true;
|
||||
startInBackground = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Docker Swarm node build type.
|
||||
#
|
||||
# Produces NixOS hosts that form a Docker Swarm manager cluster. Two nodes
|
||||
# (ha-docker-1, ha-docker-2) are both managers so either can accept Docker
|
||||
# API and `docker stack` commands.
|
||||
#
|
||||
# Key differences from the existing `docker` build type (used by CT 105):
|
||||
# - nextcloud-cron-job.nix is EXCLUDED — `docker exec` breaks in swarm
|
||||
# because the target container may be on the other node. The cron job
|
||||
# is replaced by a nextcloud-cron sidecar in the Nextcloud stack.
|
||||
# See docs/internal/docker-swarm-cutover.md.
|
||||
# - traefik/rotate-logs.nix is EXCLUDED — log rotation moves to Docker's
|
||||
# json-file log driver (max-size/max-file on the Traefik service
|
||||
# definition). See docs/internal/docker-swarm-cutover.md.
|
||||
# - raspi/mount-data.nix is EXCLUDED — specific to CT 105's backup role.
|
||||
# - Swarm firewall ports (2377/tcp, 7946/tcp+udp, 4789/udp) are opened
|
||||
# on the swarm NIC (ens20/vmbr3) only.
|
||||
# - checkReversePath = "loose" is required for the Swarm ingress routing
|
||||
# mesh: VXLAN return traffic is asymmetric (arrives ens20, exits ens18).
|
||||
# - beszel-agent is enabled for host-level monitoring.
|
||||
{ pkgs, vars, ... }:
|
||||
|
||||
{
|
||||
# Pin Docker Engine to version 29, matching CT 105, so image layers cached
|
||||
# on NFS volumes remain compatible across old and new hosts.
|
||||
nixpkgs.overlays = [
|
||||
(final: prev: {
|
||||
docker = prev.docker_29;
|
||||
docker_cli = prev.docker_29;
|
||||
})
|
||||
];
|
||||
|
||||
imports = [
|
||||
../docker/enable-service.nix
|
||||
../docker/mount-data.nix
|
||||
../docker/docker-health-to-gotify.nix
|
||||
../beszel/enable-agent.nix
|
||||
../services/enable-rpcbind.nix
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
nfs-utils
|
||||
];
|
||||
|
||||
boot.supportedFilesystems = [ "nfs" ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
# Symlink ~/docker → NFS config mount so the docker-health-to-gotify
|
||||
# script (and operator convenience) resolves ~/docker/... correctly.
|
||||
"L+ /home/${vars.primaryUser}/docker - - - - ${vars.nfsShares.dockerConfig.mountpoint}"
|
||||
"d /mnt/docker 0755 ${vars.primaryUser} users -"
|
||||
];
|
||||
|
||||
users.users.${vars.primaryUser}.extraGroups = [ "docker" ];
|
||||
|
||||
networking.firewall = {
|
||||
# LAN-facing service ports — same as the existing docker build type.
|
||||
allowedTCPPorts = [
|
||||
vars.ports.dockerHttp
|
||||
vars.ports.dockerHttps
|
||||
vars.ports.dockerExtra
|
||||
vars.ports.beszelHub
|
||||
];
|
||||
|
||||
# Swarm inter-node ports restricted to the swarm NIC (ens20/vmbr3).
|
||||
# vmbr3 is an isolated internal bridge — no LAN reachability.
|
||||
interfaces.${vars.haDockerSwarmInterface} = {
|
||||
allowedTCPPorts = [
|
||||
vars.ports.dockerSwarmMgmt # 2377 — Raft + cluster management
|
||||
vars.ports.dockerSwarmDisc # 7946 — Serf gossip (TCP half)
|
||||
];
|
||||
allowedUDPPorts = [
|
||||
vars.ports.dockerSwarmDisc # 7946 — Serf gossip (UDP half)
|
||||
vars.ports.dockerSwarmVxlan # 4789 — VXLAN overlay data path
|
||||
];
|
||||
};
|
||||
|
||||
# Docker Swarm ingress routing mesh creates asymmetric routes: a request
|
||||
# arrives on ens18 (LAN) for a container that lives on ens20's VXLAN
|
||||
# overlay; the return path differs from the incoming interface. Strict
|
||||
# rp_filter drops these packets. "loose" allows them.
|
||||
checkReversePath = "loose";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# HA file server build type: DRBD + XFS + LIO iSCSI + NFS, managed by
|
||||
# Corosync + Pacemaker. Both ha-server-1 and ha-server-2 use this type.
|
||||
#
|
||||
# NFS start/stop:
|
||||
# services.nfs.server.enable = true configures /etc/exports, wires up
|
||||
# rpcbind, and loads kernel modules — but nfs-server.service.wantedBy is
|
||||
# force-cleared so systemd does NOT auto-start it at boot. Pacemaker's
|
||||
# ha-group resource group (configured by scripts/ha/cluster-init.sh)
|
||||
# starts and stops nfs-server as part of the failover sequence after the
|
||||
# XFS mount and iSCSI target are brought up on the new Active node.
|
||||
#
|
||||
# Beszel agent:
|
||||
# Enabled here via enable-agent.nix. The agent KEY (used to pair with
|
||||
# the Beszel hub) is not set yet — add it to hosts/ha-server-{1,2}/host.nix
|
||||
# under services.beszel.agent.environment.KEY once the hub accepts the
|
||||
# new agents, following the pattern in hosts/server/host.nix.
|
||||
{ lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
# Generates /etc/exports lines for all nfsShares data entries.
|
||||
# LAN (VLAN 2): NFS via vip-lan (192.168.2.229) for pxe-boot and other LAN clients.
|
||||
# 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:
|
||||
lib.concatMapStrings
|
||||
(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));
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../ha/pacemaker-stack.nix
|
||||
../ha/iscsi-target.nix
|
||||
../ha/cluster-config.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
|
||||
# xfsprogs: mkfs.xfs/xfs_info needed by cluster-init.sh.
|
||||
# openiscsi: iscsiadm needed by acceptance-tests.sh T4 (iSCSI discovery check).
|
||||
environment.systemPackages = [ pkgs.xfsprogs pkgs.openiscsi ];
|
||||
|
||||
services.nfs.server = {
|
||||
enable = true;
|
||||
exports = mkNfsExports vars.haStorageRoot;
|
||||
};
|
||||
|
||||
# Pacemaker controls nfs-server — prevent systemd from starting it at boot
|
||||
# on both nodes (only the Active node should be serving NFS).
|
||||
systemd.services.nfs-server.wantedBy = lib.mkForce [ ];
|
||||
|
||||
# Same reason as server.nix: exports use standard auth, not Kerberos.
|
||||
systemd.services.rpc-svcgssd.enable = false;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ let
|
||||
tftpRoot = "${pxeRoot}/tftp";
|
||||
pxeBaseUrl = "http://${vars.pxeServerIp}";
|
||||
|
||||
# Base network address extracted from lanCidr (e.g. "192.168.2.0" from
|
||||
# "192.168.2.0/24") — used by dnsmasq's proxy DHCP range directive.
|
||||
lanBaseAddr = lib.head (lib.splitString "/" vars.lanCidr);
|
||||
|
||||
bootIpxe = pkgs.writeText "boot.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
@@ -21,6 +25,216 @@ let
|
||||
chain ${pxeBaseUrl}/boot.ipxe
|
||||
'';
|
||||
|
||||
debianRelease = "bookworm";
|
||||
debianMirror = "https://deb.debian.org/debian";
|
||||
debianNetbootBase = "${debianMirror}/dists/${debianRelease}/main/installer-amd64/current/images/netboot/debian-installer/amd64";
|
||||
|
||||
rockyRelease = "9";
|
||||
rockyArch = "x86_64";
|
||||
rockyMirror = "https://dl.rockylinux.org/pub/rocky/${rockyRelease}";
|
||||
rockyPxebootBase = "${rockyMirror}/BaseOS/${rockyArch}/os/images/pxeboot";
|
||||
|
||||
debianIpxe = pkgs.writeText "debian.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
kernel ''${base}/debian/linux
|
||||
initrd ''${base}/debian/initrd.gz
|
||||
boot
|
||||
'';
|
||||
|
||||
fetchDebianNetboot = pkgs.writeShellScript "fetch-debian-netboot" ''
|
||||
set -eu
|
||||
|
||||
dir="${httpRoot}/debian"
|
||||
mirror="${debianNetbootBase}"
|
||||
|
||||
if [ -f "$dir/linux" ] && [ -f "$dir/initrd.gz" ]; then
|
||||
echo "Debian ${debianRelease} netboot files already present; skipping download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading Debian ${debianRelease} netboot kernel and initrd from $mirror ..."
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/linux.tmp" "$mirror/linux"
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/initrd.gz.tmp" "$mirror/initrd.gz"
|
||||
mv "$dir/linux.tmp" "$dir/linux"
|
||||
mv "$dir/initrd.gz.tmp" "$dir/initrd.gz"
|
||||
echo "Debian ${debianRelease} netboot files staged."
|
||||
'';
|
||||
|
||||
# Rocky Linux 9 iPXE script — boots vmlinuz+initrd.img from the staged
|
||||
# /rocky/ directory and hands Anaconda the hosted Kickstart URL.
|
||||
# net.ifnames=0 biosdevname=0 ensures the NIC is eth0 in both the
|
||||
# installer and the installed system (matches the Kickstart NM config).
|
||||
rockyFreeIpaIpxe = pkgs.writeText "rocky-freeipa.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
kernel ''${base}/rocky/vmlinuz inst.ks=''${base}/rocky-freeipa.ks inst.repo=${rockyMirror}/BaseOS/${rockyArch}/os/ net.ifnames=0 biosdevname=0 ip=dhcp quiet
|
||||
initrd ''${base}/rocky/initrd.img
|
||||
boot
|
||||
'';
|
||||
|
||||
# Kickstart file for ${vars.ipaServer}.
|
||||
# Installs Rocky Linux 9, sets a static IP, creates ${vars.ipaUser} with
|
||||
# the admin SSH key, then on first reboot runs ipa-server-install via a
|
||||
# systemd oneshot service. Passwords are generated at %post time, written
|
||||
# to /root/ipa-credentials.txt (chmod 600), and read back by the
|
||||
# first-boot script — never hardcoded here or in the repo.
|
||||
rockyFreeIpaKs = pkgs.writeText "rocky-freeipa.ks" ''
|
||||
#version=RHEL9
|
||||
# Unattended Rocky Linux 9 + FreeIPA install
|
||||
# Target: ${vars.ipaServer} ${vars.domainControllerIp}
|
||||
|
||||
url --url=${rockyMirror}/BaseOS/${rockyArch}/os/
|
||||
repo --name=appstream --baseurl=${rockyMirror}/AppStream/${rockyArch}/os/
|
||||
|
||||
lang en_US.UTF-8
|
||||
keyboard us
|
||||
timezone UTC --utc
|
||||
|
||||
# DHCP during install; static IP configured in %post via NM config file
|
||||
network --bootproto=dhcp --device=link --activate
|
||||
network --hostname=${vars.ipaServer}
|
||||
|
||||
selinux --enforcing
|
||||
firewall --enabled --service=ssh
|
||||
|
||||
rootpw --lock
|
||||
user --name=${vars.ipaUser} --groups=wheel --shell=/bin/bash
|
||||
sshkey --username=${vars.ipaUser} "${vars.adminSshKey}"
|
||||
|
||||
zerombr
|
||||
clearpart --all --initlabel --drives=sda
|
||||
# Keep net.ifnames=0 biosdevname=0 in the installed GRUB so the NIC
|
||||
# stays eth0 after reboot (matches the NM connection file below).
|
||||
bootloader --location=mbr --boot-drive=sda --append="net.ifnames=0 biosdevname=0"
|
||||
|
||||
part /boot --fstype=xfs --size=1024 --ondisk=sda
|
||||
part swap --fstype=swap --size=2048 --ondisk=sda
|
||||
part / --fstype=xfs --grow --size=1 --ondisk=sda --asprimary
|
||||
|
||||
%packages
|
||||
@^minimal-environment
|
||||
ipa-server
|
||||
ipa-server-dns
|
||||
%end
|
||||
|
||||
reboot
|
||||
|
||||
%post --log=/root/ks-post.log
|
||||
set -euo pipefail
|
||||
|
||||
# -- Static IP: write NM connection file directly (NM not running in chroot) --
|
||||
mkdir -p /etc/NetworkManager/system-connections
|
||||
cat > /etc/NetworkManager/system-connections/eth0.nmconnection << 'NMCONN'
|
||||
[connection]
|
||||
id=eth0
|
||||
type=ethernet
|
||||
interface-name=eth0
|
||||
autoconnect=true
|
||||
|
||||
[ethernet]
|
||||
|
||||
[ipv4]
|
||||
method=manual
|
||||
addresses=${vars.domainControllerIp}/${toString vars.lanPrefixLength}
|
||||
gateway=${vars.lanGateway}
|
||||
dns=${vars.domainControllerIp};
|
||||
dns-search=${vars.homeDomain};
|
||||
|
||||
[ipv6]
|
||||
method=auto
|
||||
NMCONN
|
||||
chmod 600 /etc/NetworkManager/system-connections/eth0.nmconnection
|
||||
|
||||
# -- /etc/hosts: FQDN must resolve to the real IP (not loopback) for IPA --
|
||||
sed -i '/domain-controller/d' /etc/hosts
|
||||
echo '${vars.domainControllerIp} ${vars.ipaServer} domain-controller' >> /etc/hosts
|
||||
|
||||
# -- Generate IPA passwords and store securely --
|
||||
DM_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
|
||||
ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
|
||||
printf 'Directory Manager: %s\nIPA Admin: %s\n' "$DM_PASS" "$ADMIN_PASS" \
|
||||
> /root/ipa-credentials.txt
|
||||
chmod 600 /root/ipa-credentials.txt
|
||||
|
||||
# -- First-boot script: reads passwords back, runs ipa-server-install --
|
||||
cat > /usr/local/sbin/freeipa-first-boot.sh << 'FIRSTBOOT'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
exec >> /root/freeipa-install.log 2>&1
|
||||
echo "=== FreeIPA first-boot install started at $(date) ==="
|
||||
|
||||
DM_PASS=$(grep '^Directory Manager:' /root/ipa-credentials.txt | awk '{print $NF}')
|
||||
ADMIN_PASS=$(grep '^IPA Admin:' /root/ipa-credentials.txt | awk '{print $NF}')
|
||||
|
||||
ipa-server-install \
|
||||
--realm=${lib.strings.toUpper vars.homeDomain} \
|
||||
--domain=${vars.homeDomain} \
|
||||
--hostname=${vars.ipaServer} \
|
||||
--ds-password="$DM_PASS" \
|
||||
--admin-password="$ADMIN_PASS" \
|
||||
--setup-dns \
|
||||
--forwarder=${vars.domainControllerIp} \
|
||||
--no-dnssec-validation \
|
||||
--no-ntp \
|
||||
--unattended
|
||||
|
||||
echo "=== FreeIPA install complete at $(date) ==="
|
||||
echo "Credentials: /root/ipa-credentials.txt (save to password manager)"
|
||||
echo "CA backup: /root/cacert.p12 (encrypted with Directory Manager password)"
|
||||
systemctl disable freeipa-first-boot.service
|
||||
FIRSTBOOT
|
||||
chmod 700 /usr/local/sbin/freeipa-first-boot.sh
|
||||
|
||||
# -- Systemd oneshot service: runs freeipa-first-boot.sh on first real boot --
|
||||
cat > /etc/systemd/system/freeipa-first-boot.service << 'UNIT'
|
||||
[Unit]
|
||||
Description=FreeIPA first-boot installation
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/root/ipa-credentials.txt
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/freeipa-first-boot.sh
|
||||
TimeoutStartSec=1800
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
mkdir -p /etc/systemd/system/multi-user.target.wants
|
||||
ln -sf /etc/systemd/system/freeipa-first-boot.service \
|
||||
/etc/systemd/system/multi-user.target.wants/freeipa-first-boot.service
|
||||
|
||||
echo "Kickstart %post complete. FreeIPA installs on first reboot (~20 min)."
|
||||
%end
|
||||
'';
|
||||
|
||||
fetchRockyPxeboot = pkgs.writeShellScript "fetch-rocky-pxeboot" ''
|
||||
set -eu
|
||||
|
||||
dir="${httpRoot}/rocky"
|
||||
base="${rockyPxebootBase}"
|
||||
|
||||
if [ -f "$dir/vmlinuz" ] && [ -f "$dir/initrd.img" ]; then
|
||||
echo "Rocky Linux ${rockyRelease} pxeboot files already present; skipping download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading Rocky Linux ${rockyRelease} pxeboot kernel and initrd from $base ..."
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/vmlinuz.tmp" "$base/vmlinuz"
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/initrd.img.tmp" "$base/initrd.img"
|
||||
mv "$dir/vmlinuz.tmp" "$dir/vmlinuz"
|
||||
mv "$dir/initrd.img.tmp" "$dir/initrd.img"
|
||||
echo "Rocky Linux ${rockyRelease} pxeboot files staged."
|
||||
'';
|
||||
|
||||
systemRescueIpxe = pkgs.writeText "systemrescue.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
@@ -68,11 +282,13 @@ let
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
menu PXE Boot Menu
|
||||
item auto-installer NixOS Auto-Installer
|
||||
item nixos-minimal NixOS Minimal
|
||||
item rescue Rescue Environment
|
||||
item shell iPXE Shell
|
||||
item reboot Reboot
|
||||
item auto-installer NixOS Auto-Installer
|
||||
item nixos-minimal NixOS Minimal
|
||||
item debian Debian Minimal
|
||||
item rocky-freeipa FreeIPA Server (Rocky Linux 9)
|
||||
item rescue Rescue Environment
|
||||
item shell iPXE Shell
|
||||
item reboot Reboot
|
||||
|
||||
choose target && goto ''${target}
|
||||
|
||||
@@ -82,6 +298,12 @@ let
|
||||
:nixos-minimal
|
||||
chain ''${base}/nixos-minimal/netboot.ipxe
|
||||
|
||||
:debian
|
||||
chain ''${base}/debian.ipxe
|
||||
|
||||
:rocky-freeipa
|
||||
chain ''${base}/rocky-freeipa.ipxe
|
||||
|
||||
:rescue
|
||||
chain ''${base}/systemrescue.ipxe
|
||||
|
||||
@@ -95,6 +317,8 @@ in
|
||||
{
|
||||
imports = [
|
||||
../pxe-boot/stage-installer-artifacts.nix
|
||||
../pxe-boot/mount-pxe-images.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
@@ -121,45 +345,107 @@ in
|
||||
atftpd = {
|
||||
enable = true;
|
||||
root = tftpRoot;
|
||||
extraOptions = [
|
||||
"--verbose=5"
|
||||
];
|
||||
extraOptions = [ "--verbose=5" ];
|
||||
};
|
||||
|
||||
openssh.settings.PermitRootLogin = "yes";
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${pxeRoot} 0755 root root -"
|
||||
"d ${httpRoot} 0755 root root -"
|
||||
"d ${httpRoot}/images 0755 root root -"
|
||||
"d ${httpRoot}/auto-installer 0755 root root -"
|
||||
"d ${httpRoot}/nixos-minimal 0755 root root -"
|
||||
"d ${httpRoot}/systemrescue 0755 root root -"
|
||||
"d ${httpRoot}/ubuntu 0755 root root -"
|
||||
"d ${httpRoot}/rescue 0755 root root -"
|
||||
"d ${tftpRoot} 0755 root root -"
|
||||
"C+ ${httpRoot}/boot.ipxe 0644 root root - ${bootIpxe}"
|
||||
"C+ ${httpRoot}/menu.ipxe 0644 root root - ${menuIpxe}"
|
||||
"C+ ${httpRoot}/systemrescue.ipxe 0644 root root - ${systemRescueIpxe}"
|
||||
"C+ ${tftpRoot}/autoexec.ipxe 0644 root root - ${autoexecIpxe}"
|
||||
"C+ ${tftpRoot}/ipxe.efi 0644 root root - ${pkgs.ipxe}/ipxe.efi"
|
||||
"C+ ${tftpRoot}/undionly.kpxe 0644 root root - ${pkgs.ipxe}/undionly.kpxe"
|
||||
];
|
||||
|
||||
systemd.services.stage-systemrescue = {
|
||||
description = "Stage SystemRescue ISO contents for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
systemd = {
|
||||
tmpfiles.rules = [
|
||||
"d ${pxeRoot} 0755 root root -"
|
||||
"d ${httpRoot} 0755 root root -"
|
||||
"L+ ${httpRoot}/images - - - - ${vars.nfsShares.pxebootImages.mountpoint}"
|
||||
"d ${httpRoot}/auto-installer 0755 root root -"
|
||||
"d ${httpRoot}/nixos-minimal 0755 root root -"
|
||||
"d ${httpRoot}/systemrescue 0755 root root -"
|
||||
"d ${httpRoot}/debian 0755 root root -"
|
||||
"d ${httpRoot}/ubuntu 0755 root root -"
|
||||
"d ${httpRoot}/rescue 0755 root root -"
|
||||
"d ${httpRoot}/rocky 0755 root root -"
|
||||
"d ${tftpRoot} 0755 root root -"
|
||||
"C+ ${httpRoot}/boot.ipxe 0644 root root - ${bootIpxe}"
|
||||
"C+ ${httpRoot}/menu.ipxe 0644 root root - ${menuIpxe}"
|
||||
"C+ ${httpRoot}/debian.ipxe 0644 root root - ${debianIpxe}"
|
||||
"C+ ${httpRoot}/rocky-freeipa.ipxe 0644 root root - ${rockyFreeIpaIpxe}"
|
||||
"C+ ${httpRoot}/rocky-freeipa.ks 0644 root root - ${rockyFreeIpaKs}"
|
||||
"C+ ${httpRoot}/systemrescue.ipxe 0644 root root - ${systemRescueIpxe}"
|
||||
"C+ ${tftpRoot}/autoexec.ipxe 0644 root root - ${autoexecIpxe}"
|
||||
"C+ ${tftpRoot}/ipxe.efi 0644 root root - ${pkgs.ipxe}/ipxe.efi"
|
||||
"C+ ${tftpRoot}/undionly.kpxe 0644 root root - ${pkgs.ipxe}/undionly.kpxe"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = stageSystemRescue;
|
||||
|
||||
services = {
|
||||
fetch-debian-netboot = {
|
||||
description = "Download Debian ${debianRelease} netboot kernel and initrd for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = fetchDebianNetboot;
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
};
|
||||
|
||||
fetch-rocky-pxeboot = {
|
||||
description = "Download Rocky Linux ${rockyRelease} pxeboot kernel and initrd for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = fetchRockyPxeboot;
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
};
|
||||
|
||||
stage-systemrescue = {
|
||||
description = "Stage SystemRescue ISO contents for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = stageSystemRescue;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
settings = {
|
||||
# Disable DNS listener — only proxy DHCP is needed here.
|
||||
# Without this dnsmasq tries to bind port 53 which systemd-resolved
|
||||
# already owns, causing startup failure.
|
||||
port = 0;
|
||||
dhcp-range = [ "${lanBaseAddr},proxy" ];
|
||||
dhcp-match = [
|
||||
"set:ipxe,175"
|
||||
"set:efi64,option:client-arch,7"
|
||||
"set:efi64,option:client-arch,9"
|
||||
];
|
||||
dhcp-userclass = "set:ipxe,iPXE";
|
||||
dhcp-boot = [
|
||||
"tag:ipxe,tag:efi64,http://${vars.pxeServerIp}/boot.ipxe"
|
||||
"tag:ipxe,http://${vars.pxeServerIp}/boot.ipxe"
|
||||
"tag:efi64,ipxe.efi,,${vars.pxeServerIp}"
|
||||
"undionly.kpxe,,${vars.pxeServerIp}"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ vars.ports.pxeBootHttp ];
|
||||
networking.firewall.allowedUDPPorts = [ vars.ports.pxeBootTftp ];
|
||||
networking.firewall.allowedUDPPorts = [ vars.ports.pxeBootTftp vars.ports.dhcp ];
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{ vars, lib, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../beszel/enable-agent.nix
|
||||
../services/zfs/enable-service.nix
|
||||
];
|
||||
|
||||
boot.zfs.extraPools = [ (lib.removePrefix "/" vars.storageRoot) ];
|
||||
|
||||
systemd.services.nfs-server = {
|
||||
after = [ "zfs-mount.service" ];
|
||||
requires = [ "zfs-mount.service" ];
|
||||
};
|
||||
|
||||
services.nfs.server = {
|
||||
enable = true;
|
||||
exports = ''
|
||||
${vars.storageRoot}/${vars.nfsShares.dockerConfig.subpath} ${vars.lanCidr}(rw,sync,no_subtree_check,no_root_squash)
|
||||
${vars.storageRoot}/${vars.nfsShares.dockerVolumes.subpath} ${vars.lanCidr}(rw,sync,no_subtree_check,no_root_squash)
|
||||
${vars.storageRoot}/${vars.nfsShares.dockerDatabases.subpath} ${vars.lanCidr}(rw,sync,no_subtree_check,no_root_squash)
|
||||
${vars.storageRoot}/${vars.nfsShares.nextcloudData.subpath} ${vars.lanCidr}(rw,sync,no_subtree_check,no_root_squash)
|
||||
${vars.storageRoot}/${vars.nfsShares.raspiVolumes.subpath} ${vars.lanCidr}(rw,sync,no_subtree_check,no_root_squash)
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ vars.ports.nfsRpcbind vars.ports.nfsd ];
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{ ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../tailscale/exit-node.nix
|
||||
];
|
||||
|
||||
# "server", not "both": this build type only ever advertises itself as an
|
||||
# exit node (see ../tailscale/exit-node.nix) -- it doesn't advertise LAN
|
||||
# subnet routes, so it doesn't need the "client"-side loose reverse-path
|
||||
# filtering that "both" would also turn on. Deliberately left unbundled
|
||||
# from LAN-subnet-route advertisement so this build type stays valid on
|
||||
# every platform, including linode (a remote VPS with no network path to
|
||||
# the home LAN at all).
|
||||
services.tailscale.useRoutingFeatures = "server";
|
||||
|
||||
# Forwarded exit-node traffic arrives on tailscale0 already
|
||||
# tailscale-authenticated -- the firewall's normal per-port allow-list
|
||||
# would otherwise drop it. Standard NixOS/Tailscale exit-node guidance.
|
||||
networking.firewall.trustedInterfaces = [ "tailscale0" ];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../tailscale/subnet-router.nix
|
||||
../tailscale/ts-dns-forwarder.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
|
||||
# "server", not "both": this build type advertises LAN subnet routes but
|
||||
# doesn't use another tailscale exit node itself, so it doesn't need the
|
||||
# "client"-side loose reverse-path filtering that "both" would also enable.
|
||||
# Deliberately kept explicit here (not just relying on subnet-router.nix's
|
||||
# own setting) so the intent is clear at the build-type level.
|
||||
services.tailscale.useRoutingFeatures = "server";
|
||||
|
||||
# Advertise the LAN subnet so Tailscale peers can route back to LAN machines.
|
||||
# Must also be approved in the Tailscale admin console (Machines → Edit route settings).
|
||||
services.tailscale.extraUpFlags = [ "--advertise-routes=${vars.lanCidr}" ];
|
||||
|
||||
networking.firewall = {
|
||||
# Forwarded subnet-router traffic arrives on tailscale0 already
|
||||
# tailscale-authenticated -- the firewall's normal per-port allow-list
|
||||
# would otherwise drop it. Standard NixOS/Tailscale subnet-router guidance.
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
|
||||
# SNAT LAN traffic going into Tailscale so the remote peer sees it as
|
||||
# coming from this router's Tailscale IP rather than a raw LAN IP.
|
||||
# Without this, Tailscale drops forwarded packets whose source is not a
|
||||
# recognised Tailscale address.
|
||||
#
|
||||
# We target POSTROUTING directly (always-existing built-in chain) rather
|
||||
# than nixos-nat-post: extraCommands runs after the old nixos-nat-post is
|
||||
# deleted but before the new one is created, so -A nixos-nat-post silently
|
||||
# fails. The -C check makes the rule idempotent across firewall reloads.
|
||||
extraCommands = ''
|
||||
iptables -t nat -C POSTROUTING -s ${vars.lanCidr} -o tailscale0 -j MASQUERADE 2>/dev/null || \
|
||||
iptables -t nat -A POSTROUTING -s ${vars.lanCidr} -o tailscale0 -j MASQUERADE
|
||||
'';
|
||||
extraStopCommands = ''
|
||||
iptables -t nat -D POSTROUTING -s ${vars.lanCidr} -o tailscale0 -j MASQUERADE 2>/dev/null || true
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
{
|
||||
imports = [
|
||||
../tor/enable-relay.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{ pkgs, ... }: {
|
||||
# Defines the SSH host key as a clan vars generator so that:
|
||||
# - `clan vars generate <target>` creates and encrypts the key pair
|
||||
# - The private key lives at vars/per-machine/<target>/openssh/ssh_host_ed25519_key/secret
|
||||
# (sops binary-encrypted, admin-key-only; decrypted by the build script)
|
||||
# - The public key lives at vars/per-machine/<target>/openssh/ssh_host_ed25519_key.pub/value
|
||||
# (plaintext; used by sync-host-keys.sh to derive the sops age fingerprint)
|
||||
#
|
||||
# neededFor = "activation" means clan's deployment tool would upload this
|
||||
# before running nixos-rebuild/nixos-install (for VM/baremetal via
|
||||
# nixos-anywhere). For lxc-* hosts, the build script bakes it into the
|
||||
# tarball directly via NIXOS_HOST_KEYS_DIR -- the neededFor value here
|
||||
# simply ensures it is NOT mapped to sops.secrets (which would try to
|
||||
# decrypt it at runtime as a regular service secret, which is wrong: the
|
||||
# SSH host key reaches the container via the tarball, not sops).
|
||||
clan.core.vars.generators.openssh = {
|
||||
files."ssh_host_ed25519_key" = {
|
||||
secret = true;
|
||||
neededFor = "activation";
|
||||
};
|
||||
files."ssh_host_ed25519_key.pub" = {
|
||||
secret = false;
|
||||
neededFor = "activation";
|
||||
};
|
||||
runtimeInputs = [ pkgs.openssh ];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out/ssh_host_ed25519_key"
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,50 +1,7 @@
|
||||
{ config, pkgs, lib, vars, ... }:
|
||||
_:
|
||||
|
||||
let
|
||||
# Flake attribute names are now <platform>-<buildtype> (e.g. proxmox-docker)
|
||||
# and no longer match networking.hostName, since a host's hostname stays
|
||||
# fixed while the platform backing it can change. Each nixosConfiguration
|
||||
# stamps its own active target name into /etc/flake-target at build time.
|
||||
mySwitchCmd = ''
|
||||
sudo nixos-rebuild switch \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake git+https://${vars.lanDomain}/beatzaplenty/nixos.git#$(cat /etc/flake-target)
|
||||
'';
|
||||
myTestCmd = ''
|
||||
sudo nixos-rebuild test \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake git+https://${vars.lanDomain}/beatzaplenty/nixos.git#$(cat /etc/flake-target)
|
||||
'';
|
||||
|
||||
# lxc-* hosts pre-seed their SSH host key at build time (see
|
||||
# modules/platforms/lxc.nix) so sops-nix's .sops.yaml recipient matches on
|
||||
# first boot -- without it, secrets permanently fail to decrypt (see that
|
||||
# file's comment for the confirmed failure). That requires --impure plus
|
||||
# NIXOS_HOST_KEYS_DIR pointing at the repo's host-keys/ dir, same pattern
|
||||
# docs/auto-installer.md uses for the installer ISO. A function, not a
|
||||
# shellAlias, since the target name has to interpolate into the middle of
|
||||
# the flake attribute path, not just append after it. Must be run from the
|
||||
# repo root, same as every other host-keys/ command in this repo.
|
||||
buildImageFn = ''
|
||||
buildImage() {
|
||||
if [ -z "$1" ]; then
|
||||
echo "usage: buildImage <flake-target> (e.g. lxc-docker)" >&2
|
||||
return 1
|
||||
fi
|
||||
NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build --impure \
|
||||
".#nixosConfigurations.$1.config.system.build.tarball"
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
programs.bash = {
|
||||
enable = true;
|
||||
shellAliases = {
|
||||
"Switch-nix" = mySwitchCmd;
|
||||
"Test-nix" = myTestCmd;
|
||||
};
|
||||
initExtra = buildImageFn;
|
||||
};
|
||||
# Switch-nix, Test-nix, and buildImage are defined system-wide in
|
||||
# modules/common/configuration.nix so all users (including IPA accounts)
|
||||
# get them. Add any Home-Manager-only per-user shell config here.
|
||||
}
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
{ config, lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
switchCmd = ''
|
||||
sudo nixos-rebuild switch \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake git+https://${vars.giteaDomain}/${vars.giteaRepoPath}.git?dir=${vars.giteaRepoFlakePath}#$(cat /etc/flake-target)
|
||||
'';
|
||||
testCmd = ''
|
||||
sudo nixos-rebuild test \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake git+https://${vars.giteaDomain}/${vars.giteaRepoPath}.git?dir=${vars.giteaRepoFlakePath}#$(cat /etc/flake-target)
|
||||
'';
|
||||
buildImageFn = ''
|
||||
buildImage() {
|
||||
if [ -z "$1" ]; then
|
||||
echo "usage: buildImage <flake-target> (e.g. lxc-docker)" >&2
|
||||
return 1
|
||||
fi
|
||||
NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build --impure \
|
||||
".#nixosConfigurations.$1.config.system.build.tarball"
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports =
|
||||
[
|
||||
# Include the results of the hardware scan.
|
||||
# ./hardware-configuration.nix
|
||||
./set-locale.nix
|
||||
];
|
||||
# Use the GRUB 2 boot loader.
|
||||
# boot.loader.grub.enable = true;
|
||||
#boot.loader.grub.device = "/dev/sda"; # or "nodev" for efi only
|
||||
imports = [
|
||||
./set-locale.nix
|
||||
../ipa/client.nix
|
||||
];
|
||||
|
||||
networking.networkmanager.enable = true; # Easiest to use and most distros use this by default.
|
||||
# System-wide shell config so all users (including IPA accounts) get the
|
||||
# same management aliases as the local nixos user's Home Manager provides.
|
||||
programs.bash = {
|
||||
shellAliases = {
|
||||
"Switch-nix" = switchCmd;
|
||||
"Test-nix" = testCmd;
|
||||
};
|
||||
interactiveShellInit = buildImageFn;
|
||||
};
|
||||
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
# Recommended over the true default (bypasses ZFS's own import safeguards)
|
||||
# per the option's own docs; matches hosts/docker/host.nix and
|
||||
# modules/services/zfs/enable-service.nix, which already set this
|
||||
# explicitly. Harmless no-op on hosts that don't use ZFS at all.
|
||||
# modules/services/zfs/enable-service.nix. Harmless no-op on hosts without ZFS.
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
# Set your time zone.
|
||||
time.timeZone = vars.timeZone;
|
||||
|
||||
# Enable QEMU agent
|
||||
services.qemuGuest.enable = true;
|
||||
|
||||
# Enable docker-compose
|
||||
environment.systemPackages = with pkgs; [
|
||||
vim
|
||||
btop
|
||||
@@ -35,11 +60,10 @@
|
||||
];
|
||||
|
||||
# Secrets shared by every host, decrypted at activation via each host's
|
||||
# existing SSH host key (sops-nix derives the age key from
|
||||
# /etc/ssh/ssh_host_ed25519_key automatically — see modules/common/README
|
||||
# or docs/ for the sops workflow). hashedPassword/hashedPasswordFile need
|
||||
# SSH host key (sops-nix derives the age key from
|
||||
# /etc/ssh/ssh_host_ed25519_key automatically). hashedPassword secrets need
|
||||
# neededForUsers so they're available before the normal secret-activation
|
||||
# step, since user creation happens very early in boot.
|
||||
# step — user creation happens very early in boot.
|
||||
sops = {
|
||||
defaultSopsFile = ../../secrets/common.yaml;
|
||||
|
||||
@@ -47,59 +71,52 @@
|
||||
"root-hashedPassword".neededForUsers = true;
|
||||
"nixos-hashedPassword".neededForUsers = true;
|
||||
"nix-github-token" = { };
|
||||
"nix-gitea-token" = { };
|
||||
};
|
||||
|
||||
# nix.conf doesn't support a *File-style option for access-tokens, so the
|
||||
# token is rendered into a runtime-only file (never touches the Nix store)
|
||||
# and pulled in via nix.conf's native !include directive.
|
||||
templates."nix-github-token.conf".content = ''
|
||||
access-tokens = github.com=${config.sops.placeholder."nix-github-token"}
|
||||
# nix.conf has no *File-style option for access-tokens, so tokens are
|
||||
# rendered into a runtime-only file (never touches the Nix store) and
|
||||
# pulled in via nix.conf's native !include directive.
|
||||
templates."nix-access-tokens.conf".content = ''
|
||||
access-tokens = github.com=${config.sops.placeholder."nix-github-token"} ${vars.giteaDomain}=${config.sops.placeholder."nix-gitea-token"}
|
||||
'';
|
||||
};
|
||||
|
||||
nix.extraOptions = ''
|
||||
!include ${config.sops.templates."nix-github-token.conf".path}
|
||||
!include ${config.sops.templates."nix-access-tokens.conf".path}
|
||||
'';
|
||||
|
||||
#Set root password
|
||||
users.users.root = {
|
||||
hashedPasswordFile = config.sops.secrets."root-hashedPassword".path;
|
||||
users = {
|
||||
# mutableUsers = false makes update-users-groups.pl enforce hashedPasswordFile
|
||||
# on every activation, not just on newly-created accounts. Without this, a
|
||||
# freshly-built proxmox disk image (activation runs without a usable sops key,
|
||||
# so both accounts land in shadow with '!') will never have its passwords fixed
|
||||
# by subsequent boots.
|
||||
mutableUsers = false;
|
||||
|
||||
users.root = {
|
||||
hashedPasswordFile = config.sops.secrets."root-hashedPassword".path;
|
||||
};
|
||||
|
||||
users.${vars.primaryUser} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
packages = with pkgs; [ tree ];
|
||||
hashedPasswordFile = config.sops.secrets."nixos-hashedPassword".path;
|
||||
openssh.authorizedKeys.keys = [ vars.adminSshKey ] ++ vars.extraAdminSshKeys;
|
||||
};
|
||||
};
|
||||
|
||||
# Define a user account. Don't forget to set a password with ‘passwd’.
|
||||
users.users.${vars.primaryUser} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user.
|
||||
packages = with pkgs; [
|
||||
tree
|
||||
];
|
||||
hashedPasswordFile = config.sops.secrets."nixos-hashedPassword".path;
|
||||
openssh.authorizedKeys.keys = [
|
||||
vars.adminSshKey
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICMJhrfFayLBG+gWtO6oAvgambw5nWWgztiTFEaaaVRH debian@surface"
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
# Enable the OpenSSH daemon.
|
||||
services.openssh.enable = true;
|
||||
|
||||
#Enable flakes
|
||||
|
||||
nix.settings = {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
auto-optimise-store = true;
|
||||
};
|
||||
|
||||
|
||||
programs.git = {
|
||||
enable = true;
|
||||
package = pkgs.git;
|
||||
config = {
|
||||
credential.helper = "store";
|
||||
};
|
||||
config.credential.helper = "store";
|
||||
};
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared activation-script logic to preserve the SSH host key across
|
||||
# nixos-rebuild on platforms that embed the key via environment.etc (lxc and
|
||||
# proxmox). When NIXOS_HOST_KEYS_DIR is not set the key is absent from
|
||||
# environment.etc, and NixOS's etc activation removes any /etc file not in
|
||||
# the new generation — which would destroy the live key and break sops-nix
|
||||
# decryption permanently. These scripts save the key to /run before etc
|
||||
# removes it, then restore it afterward.
|
||||
#
|
||||
# Explicit deps enforce the correct ordering: without them the topological
|
||||
# sort places preserveSshHostKey after etc (confirmed live on lxc-tor-relay:
|
||||
# position 7 vs etc's position 5), so the key is gone before it can be saved.
|
||||
_: {
|
||||
system.activationScripts = {
|
||||
preserveSshHostKey = ''
|
||||
if [ -f /etc/ssh/ssh_host_ed25519_key ]; then
|
||||
cp /etc/ssh/ssh_host_ed25519_key /run/sshd-host-key-preserve.tmp
|
||||
cp /etc/ssh/ssh_host_ed25519_key.pub /run/sshd-host-key-preserve.pub.tmp
|
||||
fi
|
||||
'';
|
||||
|
||||
restoreSshHostKey = {
|
||||
deps = [ "etc" ];
|
||||
text = ''
|
||||
if [ ! -f /etc/ssh/ssh_host_ed25519_key ] && [ -f /run/sshd-host-key-preserve.tmp ]; then
|
||||
install -m 0600 /run/sshd-host-key-preserve.tmp /etc/ssh/ssh_host_ed25519_key
|
||||
install -m 0644 /run/sshd-host-key-preserve.pub.tmp /etc/ssh/ssh_host_ed25519_key.pub
|
||||
fi
|
||||
rm -f /run/sshd-host-key-preserve.tmp /run/sshd-host-key-preserve.pub.tmp
|
||||
'';
|
||||
};
|
||||
|
||||
etc = { deps = [ "preserveSshHostKey" ]; };
|
||||
setupSecrets = { deps = [ "restoreSshHostKey" ]; };
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,45 @@
|
||||
{ pkgs, ... }:
|
||||
{ lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
gid = toString vars.dockerAccessGid;
|
||||
in
|
||||
{
|
||||
# virtualisation.docker.enable = true;
|
||||
virtualisation.docker = {
|
||||
enable = true;
|
||||
package = pkgs.docker;
|
||||
# listenOptions = [
|
||||
# "unix:///var/run/docker.sock"
|
||||
# "tcp://0.0.0.0:2375"
|
||||
#];
|
||||
|
||||
# daemon.settings = {
|
||||
# metrics-addr = "0.0.0.0:9323";
|
||||
# experimental = true;
|
||||
# };
|
||||
};
|
||||
|
||||
# Pin the docker group GID to match the IPA "docker-access" group so that
|
||||
# IPA group membership alone grants access to the Docker socket. Any user
|
||||
# whose supplementary groups (resolved by SSSD from IPA) include GID
|
||||
# vars.dockerAccessGid will pass the socket group-permission check without
|
||||
# any per-host users.groups.docker.members entry.
|
||||
users.groups.docker.gid = lib.mkForce vars.dockerAccessGid;
|
||||
users.users.${vars.primaryUser}.extraGroups = [ "docker" ];
|
||||
environment.systemPackages = with pkgs; [
|
||||
docker-compose
|
||||
docker-buildx
|
||||
];
|
||||
|
||||
# NixOS's group activation uses plain `groupmod` without --non-unique.
|
||||
# When SSSD is active it exposes the IPA "docker-access" group at
|
||||
# vars.dockerAccessGid via NSS, so groupmod sees that GID as already in
|
||||
# use and silently skips the change (warning: "not applying GID change").
|
||||
# This script runs after the normal "groups" step and applies the change
|
||||
# with --non-unique (which lets the local docker group share the GID with
|
||||
# the SSSD-provided IPA group). If the GID actually changed it also
|
||||
# restarts docker.socket so the socket is recreated with the new GID.
|
||||
system.activationScripts.docker-group-gid = {
|
||||
deps = [ "groups" ];
|
||||
text = ''
|
||||
current=$(grep "^docker:" /etc/group | cut -d: -f3)
|
||||
if [ "$current" != "${gid}" ]; then
|
||||
${pkgs.shadow}/bin/groupmod --non-unique -g ${gid} docker
|
||||
if ${pkgs.systemd}/bin/systemctl is-active --quiet docker.socket; then
|
||||
${pkgs.systemd}/bin/systemctl stop docker.service docker.socket
|
||||
rm -f /var/run/docker.sock
|
||||
${pkgs.systemd}/bin/systemctl start docker.socket docker.service
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,24 +10,19 @@ let
|
||||
# non-blocking behavior, so they don't need `nofail` too).
|
||||
automountOpts = if config.boot.isContainer then [ "nofail" ] else [ "x-systemd.automount" ];
|
||||
|
||||
# A bare hostname here never resolves reliably: systemd-resolved only
|
||||
# ever tries LLMNR for single-label names (never DNS, regardless of any
|
||||
# configured search domain), and a *global* search domain (the first fix
|
||||
# attempted here) backfires worse -- confirmed live on lxc-docker, adding
|
||||
# `networking.search` made systemd-resolved prioritize its domain-matched
|
||||
# but server-less global scope over eth0's correctly-configured one for
|
||||
# every "*.sweet.home" query, silently sending them to public fallback
|
||||
# DNS instead. `resolvectl query --interface=eth0 server.sweet.home`
|
||||
# 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}";
|
||||
# FQDN in the storage.home zone — resolves to the Pacemaker vip-storage
|
||||
# (192.168.20.229) on docker's eth1/vmbr2 interface. Using the DNS name
|
||||
# rather than the raw IP means a future VIP renumber only requires a DNS
|
||||
# update, not a NixOS rebuild. The storage.home zone is served by the same
|
||||
# FreeIPA nameserver (domainControllerIp) that docker already uses, so
|
||||
# resolution reaches it over eth0 without any extra routing.
|
||||
nfsServer = vars.haStorageNfsFqdn;
|
||||
storageRoot = vars.haStorageRoot;
|
||||
in
|
||||
{
|
||||
fileSystems = {
|
||||
${vars.nfsShares.dockerConfig.mountpoint} = {
|
||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.dockerConfig.subpath}";
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerConfig.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
@@ -38,7 +33,7 @@ in
|
||||
};
|
||||
|
||||
${vars.nfsShares.dockerDatabases.mountpoint} = {
|
||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.dockerDatabases.subpath}";
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerDatabases.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
@@ -49,7 +44,7 @@ in
|
||||
};
|
||||
|
||||
${vars.nfsShares.dockerVolumes.mountpoint} = {
|
||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
@@ -60,7 +55,7 @@ in
|
||||
};
|
||||
|
||||
${vars.nfsShares.nextcloudData.mountpoint} = {
|
||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.nextcloudData.subpath}";
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.nextcloudData.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
@@ -71,7 +66,7 @@ in
|
||||
};
|
||||
|
||||
${vars.nfsShares.raspiVolumes.mountpoint} = {
|
||||
device = "${nfsServer}:${vars.storageRoot}/${vars.nfsShares.raspiVolumes.subpath}";
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.raspiVolumes.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Cluster-wide HA config shared by both ha-server nodes.
|
||||
#
|
||||
# Covers everything that is identical on both nodes and references cluster
|
||||
# topology (node IPs, hostnames, DRBD resource). Per-node identity
|
||||
# (hostname, static IP, stateVersion) lives in hosts/ha-server-{1,2}/host.nix.
|
||||
#
|
||||
# Corosync authkey:
|
||||
# /etc/corosync/authkey (mode 0400) is managed by sops-nix below.
|
||||
# Bootstrap: run scripts/ha/cluster-init.sh on node1 to generate the key,
|
||||
# then encrypt it with: sops -e --input-type binary /etc/corosync/authkey > secrets/ha-corosync-authkey
|
||||
# Both host keys must be registered via sync-host-keys.sh first so both nodes can decrypt it.
|
||||
#
|
||||
# DRBD fencing:
|
||||
# resource-only with crm-fence-peer.sh: DRBD calls the Pacemaker-aware
|
||||
# crm-fence-peer.sh handler before promoting. The handler checks the CIB
|
||||
# to confirm the peer's DRBD resource is stopped and returns 7 (successfully
|
||||
# fenced), allowing safe promotion without requiring power-fencing (STONITH).
|
||||
# The unfence handler crm-unfence-peer.sh clears the outdate flag when the
|
||||
# peer reconnects. This is the correct setting for Pacemaker+DRBD clusters
|
||||
# with STONITH disabled; crm-fence-peer.sh replaces the need for a separate
|
||||
# STONITH device during the testing phase. Switch to resource-and-stonith
|
||||
# once the fence_pve_ssh STONITH resource is active (see
|
||||
# scripts/ha/cluster-enable-stonith.sh).
|
||||
#
|
||||
# PATH wrapper: when the DRBD kernel module invokes the fence-peer handler
|
||||
# via the UMH (User Mode Helper) mechanism it provides a minimal PATH that
|
||||
# omits /run/current-system/sw/bin. crm-fence-peer.sh calls cibadmin,
|
||||
# crm_mon etc.; if those aren't found a pipeline in the script breaks with
|
||||
# SIGPIPE. A process killed by signal has WEXITSTATUS() == 0, so the kernel
|
||||
# sees exit code 0 and logs "fence-peer helper broken, returned 0", looping
|
||||
# forever. The writeShellScript wrappers below prepend the NixOS sw path
|
||||
# before exec-ing the real handler, giving it a working Pacemaker toolchain.
|
||||
{ lib, pkgs, vars, ... }:
|
||||
let
|
||||
fencePeerWrapper = pkgs.writeShellScript "drbd-fence-peer" ''
|
||||
export PATH="/run/current-system/sw/bin:/run/current-system/sw/sbin:$PATH"
|
||||
exec /run/current-system/sw/lib/drbd/crm-fence-peer.sh "$@"
|
||||
'';
|
||||
unfencePeerWrapper = pkgs.writeShellScript "drbd-unfence-peer" ''
|
||||
export PATH="/run/current-system/sw/bin:/run/current-system/sw/sbin:$PATH"
|
||||
exec /run/current-system/sw/lib/drbd/crm-unfence-peer.sh "$@"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Root SSH access — same key set as the nixos user so all admin keys can reach root.
|
||||
users.users.root.openssh.authorizedKeys.keys = [ vars.adminSshKey ] ++ vars.extraAdminSshKeys;
|
||||
|
||||
# Passwordless sudo for wheel — operator SSHes as nixos and uses sudo for
|
||||
# cluster management commands (drbdadm, crm*, pcs, etc.)
|
||||
security.sudo.wheelNeedsPassword = lib.mkForce false;
|
||||
|
||||
# DRBD lock-file directory (drbd-utils checks for it; missing → harmless but noisy warnings).
|
||||
systemd.tmpfiles.rules = [ "d /var/lib/drbd 0750 root root -" ];
|
||||
|
||||
# Prevent drbd.service from auto-starting at boot / nixos-rebuild switch.
|
||||
# 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
|
||||
# already Primary under Pacemaker, apply-al fails with "device busy" (exit 20).
|
||||
systemd.services.drbd.wantedBy = lib.mkForce [ ];
|
||||
|
||||
services.drbd = {
|
||||
enable = true;
|
||||
config = ''
|
||||
global {
|
||||
usage-count yes;
|
||||
}
|
||||
|
||||
common {
|
||||
net {
|
||||
protocol C;
|
||||
ping-int 1;
|
||||
verify-alg sha256;
|
||||
after-sb-0pri discard-zero-changes;
|
||||
after-sb-1pri discard-secondary;
|
||||
}
|
||||
disk {
|
||||
fencing resource-only;
|
||||
}
|
||||
handlers {
|
||||
fence-peer "${fencePeerWrapper}";
|
||||
unfence-peer "${unfencePeerWrapper}";
|
||||
}
|
||||
}
|
||||
|
||||
resource ha-data {
|
||||
volume 0 {
|
||||
device /dev/drbd0;
|
||||
disk ${vars.haServerDrbdDisk};
|
||||
meta-disk internal;
|
||||
}
|
||||
|
||||
on ${vars.haServer1Host} {
|
||||
address ${vars.haServer1StorageIp}:${toString vars.ports.haServerDrbd};
|
||||
}
|
||||
|
||||
on ${vars.haServer2Host} {
|
||||
address ${vars.haServer2StorageIp}:${toString vars.ports.haServerDrbd};
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# /etc/corosync/authkey — sops binary secret, identical on both nodes.
|
||||
# Decryptable by both ha-server host keys (added by sync-host-keys.sh).
|
||||
sops.secrets.corosync_authkey = {
|
||||
sopsFile = ../../secrets/ha-corosync-authkey;
|
||||
format = "binary";
|
||||
path = "/etc/corosync/authkey";
|
||||
mode = "0400";
|
||||
restartUnits = [ "corosync.service" ];
|
||||
};
|
||||
|
||||
# NixOS common config enables NetworkManager by default; HA cluster nodes
|
||||
# need stable static IPs with predictable interface names — NM is not suitable.
|
||||
networking.networkmanager.enable = lib.mkForce false;
|
||||
|
||||
# services.corosync.enable is set by modules/ha/pacemaker-stack.nix.
|
||||
services.corosync = {
|
||||
clusterName = "ha-cluster";
|
||||
nodelist = [
|
||||
# ring0: cluster-internal vmbr1 (primary heartbeat + DRBD path)
|
||||
# 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 = {
|
||||
allowedTCPPorts = [
|
||||
vars.ports.haServerPacemakerRemoted
|
||||
vars.ports.haServerPcsd
|
||||
vars.ports.haServerDrbd
|
||||
];
|
||||
allowedUDPPorts = [
|
||||
vars.ports.haServerCorosync1
|
||||
vars.ports.haServerCorosync2
|
||||
vars.ports.haServerCorosyncCrypto
|
||||
];
|
||||
# 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 = ''
|
||||
iptables -A nixos-fw -s ${vars.haServer1Ip}/32 -j nixos-fw-accept
|
||||
iptables -A nixos-fw -s ${vars.haServer2Ip}/32 -j nixos-fw-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
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# LIO iSCSI target service (targetctl) for NixOS HA clusters.
|
||||
#
|
||||
# Provides the targetctl.service that saves/restores LIO configuration from
|
||||
# /etc/target/saveconfig.json. Pacemaker manages this service via its
|
||||
# systemd resource agent (class="systemd" type="targetctl").
|
||||
#
|
||||
# Why ExecStop is not simply "targetctl save":
|
||||
# targetctl save writes the LIO config to JSON but does NOT remove the LIO
|
||||
# target from the kernel's configfs. As a result, any fileio backing store
|
||||
# that LIO has open (e.g. iscsi-lun.img on an XFS-over-DRBD filesystem)
|
||||
# stays referenced in the kernel. The subsequent XFS umount from the
|
||||
# Filesystem OCF resource then returns EBUSY and either hangs for the full
|
||||
# op-stop timeout or fails outright, blocking the entire failover.
|
||||
#
|
||||
# The ExecStop script here additionally tears down the kernel LIO state
|
||||
# via rtslib_fb after saving, so the backing-store file descriptor is
|
||||
# released and umount succeeds immediately.
|
||||
#
|
||||
# Empty-config guard:
|
||||
# The save step is skipped when no iSCSI targets are currently active.
|
||||
# This prevents the secondary node (where LIO was never started) from
|
||||
# overwriting a valid saveconfig.json with an empty one when Pacemaker
|
||||
# stops the iscsi-target resource as part of a failover or cleanup.
|
||||
{ pkgs, ... }:
|
||||
|
||||
let
|
||||
python3 = pkgs.python3.withPackages (ps: [ ps.rtslib-fb ]);
|
||||
targetctl = "${python3}/bin/targetctl";
|
||||
|
||||
targetctlStop = pkgs.writeScript "targetctl-stop" ''
|
||||
#!${python3}/bin/python3
|
||||
import subprocess, sys
|
||||
import rtslib_fb
|
||||
|
||||
root = rtslib_fb.RTSRoot()
|
||||
targets = list(root.targets)
|
||||
if targets:
|
||||
subprocess.run(
|
||||
["${targetctl}", "save", "/etc/target/saveconfig.json"],
|
||||
capture_output=True,
|
||||
)
|
||||
print(f"saved {len(targets)} iSCSI target(s)")
|
||||
else:
|
||||
print("no active LIO targets — saveconfig.json unchanged")
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
for tpg in list(target.tpgs):
|
||||
tpg.enable = False
|
||||
target.delete()
|
||||
except Exception as e:
|
||||
print(f"warn (target): {e}", file=sys.stderr)
|
||||
for so in list(root.storage_objects):
|
||||
try:
|
||||
so.delete()
|
||||
except Exception as e:
|
||||
print(f"warn (backstore): {e}", file=sys.stderr)
|
||||
print("LIO kernel target cleared")
|
||||
'';
|
||||
in
|
||||
{
|
||||
boot.kernelModules = [
|
||||
"target_core_mod"
|
||||
"iscsi_target_mod"
|
||||
"target_core_file"
|
||||
"target_core_pscsi"
|
||||
"target_core_user"
|
||||
"configfs"
|
||||
];
|
||||
|
||||
systemd = {
|
||||
mounts = [{
|
||||
where = "/sys/kernel/config";
|
||||
what = "configfs";
|
||||
type = "configfs";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "targetctl.service" ];
|
||||
}];
|
||||
services.targetctl = {
|
||||
description = "LIO iSCSI target config save/restore";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "sys-kernel-config.mount" "network.target" ];
|
||||
requires = [ "sys-kernel-config.mount" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${targetctl} restore /etc/target/saveconfig.json";
|
||||
ExecStop = "${targetctlStop}";
|
||||
};
|
||||
unitConfig.ConditionFileNotEmpty = "/etc/target/saveconfig.json";
|
||||
};
|
||||
tmpfiles.rules = [
|
||||
"d /etc/target 0750 root root -"
|
||||
"f /etc/target/saveconfig.json 0640 root root -"
|
||||
];
|
||||
};
|
||||
|
||||
environment.systemPackages = [ pkgs.targetcli-fb ];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Pacemaker + Corosync HA stack for NixOS with known-good workarounds.
|
||||
#
|
||||
# Issues fixed here (confirmed through live testing on NixOS 25.11):
|
||||
#
|
||||
# 1. StateDirectory ownership reset: systemd's StateDirectory=pacemaker
|
||||
# creates /var/lib/pacemaker owned root:root. pacemaker-based (the CIB
|
||||
# daemon) runs as the hacluster user and calls pcmk__daemon_can_write,
|
||||
# which requires the CIB directory to be owned by hacluster or be
|
||||
# group-writable by haclient. Workaround: remove StateDirectory and let
|
||||
# ExecStartPre create every required subdirectory with correct ownership.
|
||||
#
|
||||
# 2. HA_SBIN_DIR wrong path: ocf-shellfuncs sets HA_SBIN_DIR to the Nix
|
||||
# store path of the resource-agents derivation's /sbin, which doesn't
|
||||
# exist. The DRBD OCF agent uses ${HA_SBIN_DIR}/crm_master, so it exits
|
||||
# 127 without this override. Fix: export HA_SBIN_DIR=/run/current-system/sw/bin.
|
||||
#
|
||||
# 3. Broad PATH for OCF agents: the resource executor (pacemaker-execd) runs
|
||||
# OCF agent scripts as children. NixOS provides no implicit PATH for
|
||||
# system services; without an explicit PATH the agents can't find ip, ss,
|
||||
# mount, umount, drbdadm, etc.
|
||||
#
|
||||
# 4. FUSER=true: the Filesystem OCF agent calls check_binary $FUSER (default:
|
||||
# fuser from psmisc), which is not installed. Setting FUSER=true makes
|
||||
# check_binary succeed (true is always in PATH) and the subsequent
|
||||
# "$FUSER -km $mountpoint" becomes a no-op. Pair with force_unmount=false
|
||||
# on each Filesystem resource unless you want lazy unmount behaviour.
|
||||
{ lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
ocfBinPath = lib.concatStringsSep ":" [
|
||||
"${pkgs.iproute2}/bin"
|
||||
"${pkgs.iproute2}/sbin"
|
||||
"${pkgs.iputils}/bin"
|
||||
"${pkgs.util-linux}/bin"
|
||||
"${pkgs.util-linux}/sbin"
|
||||
"${pkgs.gawk}/bin"
|
||||
"${pkgs.gnugrep}/bin"
|
||||
"${pkgs.gnused}/bin"
|
||||
"${pkgs.coreutils}/bin"
|
||||
"${pkgs.bash}/bin"
|
||||
"${pkgs.procps}/bin"
|
||||
"${pkgs.xfsprogs}/bin"
|
||||
"${pkgs.drbd}/bin"
|
||||
"${pkgs.python3}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/current-system/sw/sbin"
|
||||
"/usr/local/sbin"
|
||||
"/usr/local/bin"
|
||||
"/usr/sbin"
|
||||
"/usr/bin"
|
||||
"/sbin"
|
||||
"/bin"
|
||||
];
|
||||
|
||||
# Single pre-start script: schemas symlink + directory ownership.
|
||||
# Runs before pacemakerd so pacemaker-based finds hacluster-owned dirs.
|
||||
preStartCmd = "${pkgs.bash}/bin/bash -c '"
|
||||
+ "ln -sfn ${pkgs.pacemaker}/share/pacemaker /var/lib/pacemaker/schemas; "
|
||||
+ "for d in /var/lib/pacemaker /var/lib/pacemaker/cib /var/lib/pacemaker/cores "
|
||||
+ "/var/lib/pacemaker/pengine /var/lib/pacemaker/blackbox "
|
||||
+ "/var/lib/pacemaker/hostcache; do "
|
||||
+ "mkdir -p \"\\$d\" && chown hacluster:pacemaker \"\\$d\" && chmod 2770 \"\\$d\"; "
|
||||
+ "done'";
|
||||
|
||||
ocfEnv = {
|
||||
PATH = lib.mkForce ocfBinPath;
|
||||
OCF_ROOT = "${pkgs.ocf-resource-agents}/usr/lib/ocf";
|
||||
HA_SBIN_DIR = "/run/current-system/sw/bin";
|
||||
FUSER = "true";
|
||||
};
|
||||
in
|
||||
{
|
||||
users.groups.haclient = { };
|
||||
|
||||
services.corosync.enable = true;
|
||||
services.pacemaker.enable = true;
|
||||
|
||||
systemd.services = {
|
||||
pacemaker = {
|
||||
serviceConfig = {
|
||||
StateDirectory = lib.mkForce "";
|
||||
ExecStartPre = lib.mkBefore [ preStartCmd ];
|
||||
};
|
||||
environment = ocfEnv;
|
||||
};
|
||||
pacemaker-execd.environment = ocfEnv;
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
corosync
|
||||
pacemaker
|
||||
ocf-resource-agents
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
# Fully declarative FreeIPA domain membership.
|
||||
#
|
||||
# Imported by modules/common/configuration.nix — no per-host wiring needed.
|
||||
# Enables itself automatically on any host that has a sops-encrypted keytab
|
||||
# at secrets/<hostname>.keytab; is a no-op for all other hosts.
|
||||
#
|
||||
# To enroll a new host:
|
||||
# 0. scripts/secrets/sync-host-keys.sh <flake-target>
|
||||
# 1. scripts/ipa/create-nixos-ipa-host-account.sh [--ip <addr>] <hostname>
|
||||
# (adds .sops.yaml rule, runs ipa host-add, encrypts keytab in one step)
|
||||
# 2. git add secrets/<hostname>.keytab .sops.yaml && git commit
|
||||
# 3. Deploy — no further steps required.
|
||||
#
|
||||
# Manual fallback (if the script isn't usable):
|
||||
# a. On the FreeIPA server: ipa host-add <fqdn> [--ip-address=<ip>] --force
|
||||
# b. On the FreeIPA server: ipa-getkeytab -s <ipa-server> -p host/<fqdn> -k /tmp/<host>.keytab
|
||||
# c. From the repo root (path must match for sops creation rule to apply):
|
||||
# cp /tmp/<host>.keytab secrets/<host>.keytab
|
||||
# sops -e --input-type binary -i secrets/<host>.keytab
|
||||
# d. Commit secrets/<host>.keytab and the updated .sops.yaml, then deploy.
|
||||
#
|
||||
# vars dependencies: homeDomain, ipaServer, domainControllerIp, ipaUser
|
||||
|
||||
{ config, lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
keytabPath = ../../secrets + "/${config.networking.hostName}.keytab";
|
||||
enabled = builtins.pathExists keytabPath;
|
||||
|
||||
realm = lib.strings.toUpper vars.homeDomain;
|
||||
fqdn = "${config.networking.hostName}.${vars.homeDomain}";
|
||||
# "sweet.home" -> "dc=sweet,dc=home"
|
||||
basedn = lib.strings.concatMapStringsSep "," (c: "dc=${c}") (lib.strings.splitString "." vars.homeDomain);
|
||||
# security.ipa.certificate expects a derivation (package), not a raw path.
|
||||
caCertPkg = pkgs.writeText "ipa-ca.crt" (builtins.readFile ../../certs/ipa-ca.crt);
|
||||
in
|
||||
lib.mkIf enabled {
|
||||
networking.domain = lib.mkDefault vars.homeDomain;
|
||||
networking.nameservers = lib.mkDefault [ vars.domainControllerIp ];
|
||||
|
||||
security = {
|
||||
ipa = {
|
||||
enable = true;
|
||||
domain = vars.homeDomain;
|
||||
inherit realm;
|
||||
server = vars.ipaServer;
|
||||
certificate = caCertPkg;
|
||||
inherit basedn;
|
||||
ipaHostname = fqdn;
|
||||
offlinePasswords = true;
|
||||
cacheCredentials = true;
|
||||
};
|
||||
|
||||
# Create the home directory on first login if it doesn't exist yet.
|
||||
# IPA users have no pre-created home on the host; without this sshd
|
||||
# opens a session to a non-existent directory and resets the connection.
|
||||
# lightdm also needs this so the GUI login path can create the home dir
|
||||
# if it was not pre-seeded by the tmpfiles rule above (e.g. on first boot
|
||||
# before SSSD has resolved the user).
|
||||
pam.services = {
|
||||
sshd.makeHomeDir = true;
|
||||
lightdm.makeHomeDir = true;
|
||||
|
||||
# pam_unix returns PAM_AUTHINFO_UNAVAIL without prompting when the local
|
||||
# stub has "!" in shadow (account locked), so PAM_AUTHTOK is never set
|
||||
# and pam_sss's use_first_pass fails with "No authentication token".
|
||||
# Changing to try_first_pass makes pam_sss prompt independently when no
|
||||
# prior module has set the token, restoring IPA password login via
|
||||
# LightDM and su.
|
||||
login.rules.auth.sss.settings = lib.mkForce { try_first_pass = true; };
|
||||
su.rules.auth.sss.settings = lib.mkForce { try_first_pass = true; };
|
||||
};
|
||||
|
||||
# HM with useUserPackages = true (flake.nix) sets users.users.${ipaUser}.packages,
|
||||
# which forces the stub into /etc/passwd. pam_sss.so with the "localusers" flag
|
||||
# (added by NixOS when SSSD is enabled) then skips SSSD for any user it finds in
|
||||
# local /etc/passwd — including this stub — falling through to pam_unix, which has
|
||||
# no password for the stub → sudo auth always fails.
|
||||
#
|
||||
# Fix: NOPASSWD for the IPA user. The IPA user already authenticated to reach a
|
||||
# shell (SSH public key from IPA or Kerberos), so re-prompting via a broken PAM
|
||||
# path is security theater on a single-admin homelab.
|
||||
sudo.extraRules = [{
|
||||
users = [ vars.ipaUser ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
};
|
||||
|
||||
systemd = {
|
||||
# Fetch SSH public keys from IPA so users can log in with the key stored
|
||||
# in their IPA profile rather than needing ~/.ssh/authorized_keys on every
|
||||
# host. sss_ssh_authorizedkeys queries SSSD (which queries IPA LDAP).
|
||||
#
|
||||
# /nix/store is 1775 (group-writable by nixbld). OpenSSH 10.0+ rejects
|
||||
# AuthorizedKeysCommand binaries whose path contains any group-writable
|
||||
# component, silently skipping the command. Copy to /usr/local/bin (all
|
||||
# components root-owned, 755) so the path passes sshd's safety check.
|
||||
tmpfiles.rules = [
|
||||
"d /usr/local 0755 root root - -"
|
||||
"d /usr/local/bin 0755 root root - -"
|
||||
"C+ /usr/local/bin/sss_ssh_authorizedkeys 0555 root root - ${pkgs.sssd}/bin/sss_ssh_authorizedkeys"
|
||||
# Pre-create the IPA user's home dir so Home Manager activation succeeds
|
||||
# even before their first login. On a fresh system SSSD may not have
|
||||
# resolved the user yet — tmpfiles warns and skips in that case (non-fatal),
|
||||
# and pam_mkhomedir covers the first-login path as a fallback.
|
||||
"d /home/${vars.ipaUser} 0700 ${vars.ipaUser} ${vars.ipaUser} - -"
|
||||
];
|
||||
|
||||
# security.ipa enables Kerberos (security.krb5) which causes systemd to
|
||||
# start auth-rpcgss-module.service and rpc-gssd.service for Kerberos NFS
|
||||
# authentication. LXC containers can't load the auth_rpcgss kernel module
|
||||
# and don't have /var/lib/nfs/rpc_pipefs, so both services fail.
|
||||
#
|
||||
# The NixOS IPA module already adds a drop-in for auth-rpcgss-module.service
|
||||
# with ConditionPathExists=/etc/krb5.keytab. We use lib.mkForce to win the
|
||||
# text conflict and add ConditionVirtualization=!container alongside it so
|
||||
# the service is skipped (not failed) in containers that do have a keytab.
|
||||
# Same fix for rpc-gssd.service which also fails in containers.
|
||||
units = lib.mkIf config.boot.isContainer {
|
||||
"auth-rpcgss-module.service" = {
|
||||
overrideStrategy = "asDropinIfExists";
|
||||
text = lib.mkForce ''
|
||||
[Unit]
|
||||
ConditionPathExists=
|
||||
ConditionPathExists=/etc/krb5.keytab
|
||||
ConditionVirtualization=!container
|
||||
'';
|
||||
};
|
||||
# rpc-gssd also has ConditionPathExists from the NixOS IPA module (and an
|
||||
# X-Restart-Triggers store path from systemd.nix). Use mkForce to win;
|
||||
# omit X-Restart-Triggers since this service is skipped in containers anyway.
|
||||
"rpc-gssd.service" = {
|
||||
overrideStrategy = "asDropinIfExists";
|
||||
text = lib.mkForce ''
|
||||
[Unit]
|
||||
ConditionPathExists=
|
||||
ConditionPathExists=/etc/krb5.keytab
|
||||
ConditionVirtualization=!container
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# home-manager-<user>.service fails on first enrollment because /home/wayne
|
||||
# doesn't exist until the user's first login (pam_mkhomedir creates it then).
|
||||
# ConditionPathExists makes systemd skip the service (exit 0, condition not
|
||||
# met) instead of failing. After first login the dir exists and subsequent
|
||||
# rebuilds activate HM normally.
|
||||
services."home-manager-${vars.ipaUser}".unitConfig.ConditionPathExists =
|
||||
"/home/${vars.ipaUser}";
|
||||
};
|
||||
|
||||
services.openssh.extraConfig = ''
|
||||
AuthorizedKeysCommand /usr/local/bin/sss_ssh_authorizedkeys %u
|
||||
AuthorizedKeysCommandUser nobody
|
||||
'';
|
||||
|
||||
# Host keytab: pre-provisioned on the IPA server, sops-encrypted binary.
|
||||
# Placed at /etc/krb5.keytab before SSSD starts so the host authenticates
|
||||
# to IPA without running ipa-client-install.
|
||||
sops.secrets."ipa-host-keytab" = {
|
||||
sopsFile = keytabPath;
|
||||
format = "binary";
|
||||
path = "/etc/krb5.keytab";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
mode = "0600";
|
||||
restartUnits = [ "sssd.service" ];
|
||||
};
|
||||
|
||||
# NixOS requires isNormalUser/isSystemUser + group on any entry in
|
||||
# users.users. HM with useUserPackages = true (set in flake.nix) adds a stub
|
||||
# entry for each HM user so it can install packages to
|
||||
# /etc/profiles/per-user/<name>/. This definition satisfies those assertions.
|
||||
# With security.ipa setting "passwd: sss files" in nsswitch, SSSD's IPA entry
|
||||
# takes priority for NSS lookups — this local stub is only a fallback when
|
||||
# SSSD is unreachable (at which point auth fails anyway).
|
||||
users.users.${vars.ipaUser} = {
|
||||
isNormalUser = true;
|
||||
group = "users";
|
||||
extraGroups = [ "wheel" ];
|
||||
createHome = false;
|
||||
# "!" is not a password hash — it is the standard "account locked" marker.
|
||||
# It cannot authenticate anyone locally. It exists solely so NixOS generates
|
||||
# a shadow entry for this stub user; without one pam_unix returns
|
||||
# PAM_AUTHINFO_UNAVAIL before prompting, which means PAM_AUTHTOK is never
|
||||
# set and the subsequent pam_sss use_first_pass call has nothing to work
|
||||
# with — blocking LightDM and su logins even when IPA/SSSD auth succeeds.
|
||||
hashedPassword = "!";
|
||||
};
|
||||
|
||||
# Home Manager config for the IPA primary user, applied on every enrolled
|
||||
# host. Manages what IPA doesn't: dotfiles, user-scoped packages, session
|
||||
# variables. Switch-nix/Test-nix/buildImage are system-wide (configuration.nix)
|
||||
# so they don't need to be repeated here.
|
||||
#
|
||||
# homeDirectory uses mkForce because HM's NixOS integration module sets it to
|
||||
# "/var/empty" for users not found in config.users.users at eval time (SSSD
|
||||
# users aren't visible there).
|
||||
home-manager.users.${vars.ipaUser} = { pkgs, ... }: {
|
||||
home = {
|
||||
username = vars.ipaUser;
|
||||
homeDirectory = lib.mkForce "/home/${vars.ipaUser}";
|
||||
stateVersion = "26.05";
|
||||
packages = with pkgs; [ tmux sshfs ];
|
||||
sessionVariables.EDITOR = lib.mkDefault "nano";
|
||||
};
|
||||
programs.home-manager.enable = true;
|
||||
programs.bash.enable = true;
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
nix.settings = {
|
||||
substituters = [
|
||||
"http://${vars.nixCacheHost}"
|
||||
"http://${vars.nixCacheHost}.${vars.homeDomain}"
|
||||
"https://cache.nixos.org/"
|
||||
];
|
||||
trusted-public-keys = [
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
# dedicated keypair). If this host doesn't have one yet:
|
||||
# sudo -u root ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519
|
||||
# # then add its .pub to vars.remoteBuilderAuthorizedKeys and rebuild nix-cache
|
||||
# sudo ssh -i /root/.ssh/id_ed25519 nixremote@nix-cache nix-store --version
|
||||
# sudo ssh -i /root/.ssh/id_ed25519 nixremote@nix-cache.sweet.home nix-store --version
|
||||
# Trust nix-cache's SSH host key declaratively so the nix-daemon (root)
|
||||
# can connect the first time without a manual ssh-keyscan/known_hosts
|
||||
# step on every new client.
|
||||
programs.ssh.knownHosts.${vars.nixCacheHost} = {
|
||||
hostNames = [ vars.nixCacheHost ];
|
||||
programs.ssh.knownHosts."${vars.nixCacheHost}.${vars.homeDomain}" = {
|
||||
hostNames = [ "${vars.nixCacheHost}.${vars.homeDomain}" ];
|
||||
publicKey = vars.nixCacheHostKey;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
buildMachines = [
|
||||
{
|
||||
hostName = vars.nixCacheHost;
|
||||
hostName = "${vars.nixCacheHost}.${vars.homeDomain}";
|
||||
sshUser = vars.remoteBuilderUser;
|
||||
sshKey = "/root/.ssh/id_ed25519";
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
nginx = {
|
||||
enable = true;
|
||||
recommendedProxySettings = true;
|
||||
virtualHosts.${vars.nixCacheHost} = {
|
||||
virtualHosts."${vars.nixCacheHost}.${vars.homeDomain}" = {
|
||||
locations."/" = {
|
||||
proxyPass = "http://${config.services.nix-serve.bindAddress}:${toString config.services.nix-serve.port}";
|
||||
};
|
||||
|
||||
+60
-20
@@ -52,6 +52,7 @@ in
|
||||
# LXC container does).
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/proxmox-lxc.nix")
|
||||
../common/preserve-ssh-host-key.nix
|
||||
];
|
||||
|
||||
proxmoxLXC = {
|
||||
@@ -63,23 +64,22 @@ in
|
||||
# back to decide `pct create`'s --unprivileged flag, so the two stay
|
||||
# in sync).
|
||||
#
|
||||
# lxc-docker is the one exception: the kernel's NFS client doesn't set
|
||||
# FS_USERNS_MOUNT, so mounting NFS from inside *any* non-init user
|
||||
# namespace -- which is exactly what an unprivileged container's
|
||||
# UID-mapped root runs in -- is rejected at the VFS layer with EPERM,
|
||||
# no matter what Proxmox's own `mount=nfs;nfs4` container feature
|
||||
# allows at the AppArmor layer (confirmed live: TCP to the NFS server
|
||||
# succeeds, the server's export table matches the container's IP, and
|
||||
# `mount.nfs: Operation not permitted` still fires immediately with no
|
||||
# corresponding denial anywhere in the server's logs -- a kernel-level
|
||||
# rejection, not a network or export-permission one). Keying off
|
||||
# hostName rather than something docker-build-type-specific because
|
||||
# modules/build-types/docker.nix is also composed for linode-docker/
|
||||
# proxmox-docker, which don't import proxmox-lxc.nix at all --setting
|
||||
# this option there would break their eval with "option does not
|
||||
# exist" regardless of any mkIf guard, since mkIf only makes a value
|
||||
# conditional, not whether the option needs to exist somewhere.
|
||||
privileged = config.networking.hostName == "docker";
|
||||
# Any lxc-* host with an NFS fileSystem must be privileged: the kernel's
|
||||
# NFS client doesn't set FS_USERNS_MOUNT, so mounting NFS from inside
|
||||
# *any* non-init user namespace -- which is exactly what an unprivileged
|
||||
# container's UID-mapped root runs in -- is rejected at the VFS layer
|
||||
# with EPERM, no matter what Proxmox's own `mount=nfs;nfs4` container
|
||||
# feature allows at the AppArmor layer (confirmed live: TCP to the NFS
|
||||
# server succeeds, the server's export table matches the container's IP,
|
||||
# and `mount.nfs: Operation not permitted` still fires immediately with
|
||||
# no corresponding denial anywhere in the server's logs -- a kernel-level
|
||||
# rejection, not a network or export-permission one). Deriving this from
|
||||
# fileSystems rather than a per-host override keeps it self-consistent:
|
||||
# any new lxc-* host that declares an NFS mount automatically gets the
|
||||
# privilege level it needs without a separate manual flag.
|
||||
privileged = builtins.any
|
||||
(fs: fs.fsType == "nfs" || fs.fsType == "nfs4")
|
||||
(builtins.attrValues config.fileSystems);
|
||||
};
|
||||
|
||||
boot.loader = {
|
||||
@@ -112,9 +112,12 @@ in
|
||||
# sops-nix's "for users" secrets (password hashes -- installed by the
|
||||
# activation script itself, not a systemd service, since they need to
|
||||
# exist *before* user creation) nor the user-creation step that
|
||||
# consumes them ever run on a real lxc-* boot. Regular secrets
|
||||
# (nix-serve's key, beszel's token, etc.) work anyway because sops-nix
|
||||
# provides its own systemd service for those.
|
||||
# consumes them ever run on a real lxc-* boot. In this config sops-nix
|
||||
# does NOT generate its own boot-time service (confirmed live: no
|
||||
# sops-nix.service in systemctl list-unit-files on a deployed
|
||||
# lxc-tor-relay container); /run/secrets is a tmpfs cleared on every
|
||||
# reboot, so secrets must be reinstalled on each non-first boot by
|
||||
# nixos-lxc-sops-reinstall (below).
|
||||
#
|
||||
# A systemd service, not boot.postBootCommands: tried that first (it's
|
||||
# a genuine, generally-invoked hook -- nixos/modules/system/boot/stage-2-init.sh,
|
||||
@@ -165,4 +168,41 @@ in
|
||||
touch /var/lib/nixos-lxc-first-boot-activated
|
||||
'';
|
||||
};
|
||||
|
||||
# Reinstalls sops secrets on every non-first boot. /run/secrets is a
|
||||
# tmpfs that is cleared on each reboot; without this service, secrets
|
||||
# are permanently absent after the first boot and every service that
|
||||
# reads from /run/secrets fails on start.
|
||||
#
|
||||
# wantedBy/before network.target: switch-to-configuration test requires
|
||||
# D-Bus to restart systemd targets after running activation scripts. D-Bus
|
||||
# is available once basic.target completes (the default After=basic.target
|
||||
# that DefaultDependencies would otherwise add). Placing the service before
|
||||
# network.target ensures secrets are ready before any network-dependent
|
||||
# service (including beszel-agent and nix-serve) starts, while running late
|
||||
# enough that D-Bus is already up.
|
||||
#
|
||||
# ConditionPathExists=... skips this service on the genuine first boot
|
||||
# (the marker doesn't exist yet); nixos-lxc-first-boot-activate handles
|
||||
# that case. On every subsequent boot the condition passes and secrets
|
||||
# are reinstalled before user services start.
|
||||
#
|
||||
# SuccessExitStatus=11: switch-to-configuration exits 11 when it cannot
|
||||
# acquire the activation lock (another switch is already in progress).
|
||||
# During a nixos-rebuild switch the activation already installs secrets, so
|
||||
# treating the lock-held case as success is correct.
|
||||
systemd.services.nixos-lxc-sops-reinstall = {
|
||||
description = "Reinstall sops secrets on each non-first boot (LXC, /run is tmpfs)";
|
||||
wantedBy = [ "network.target" ];
|
||||
before = [ "network.target" ];
|
||||
unitConfig.ConditionPathExists = "/var/lib/nixos-lxc-first-boot-activated";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
SuccessExitStatus = "11";
|
||||
};
|
||||
script = ''
|
||||
/run/current-system/bin/switch-to-configuration test
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
{ ... }:
|
||||
{ lib, flakeTarget, ... }:
|
||||
|
||||
let
|
||||
# Bakes this exact flake target's pre-generated SSH host key straight
|
||||
# into /etc/ssh/ -- mirrors lxc.nix's builtins.getEnv pattern (impure
|
||||
# and empty under normal `nix build`/`nix eval`, so this is a no-op
|
||||
# unless explicitly opted into with NIXOS_HOST_KEYS_DIR=... --impure).
|
||||
#
|
||||
# Unlike --pre-format-files (which places files on the QEMU builder VM's
|
||||
# rootfs, not the target disk), embedding via environment.etc here means
|
||||
# nixos-install's own activation step installs the key onto the target
|
||||
# disk. sshd-keygen then finds it already present and skips generation,
|
||||
# so the disk image boots with the clan-registered key and sops can
|
||||
# decrypt on first boot.
|
||||
#
|
||||
# Without this, nixos-install's sshd-keygen activation generates a fresh
|
||||
# key (unregistered in .sops.yaml), sops decryption fails permanently,
|
||||
# and password hashes are never applied -- confirmed live: passwords
|
||||
# stayed '!' even with mutableUsers = false because hashedPasswordFile
|
||||
# pointed to a path that sops never wrote.
|
||||
hostKeysDirStr = builtins.getEnv "NIXOS_HOST_KEYS_DIR";
|
||||
hasHostKeysDir = hostKeysDirStr != "" && builtins.pathExists hostKeysDirStr;
|
||||
hostKeysDir = /. + hostKeysDirStr;
|
||||
|
||||
privKeyFile = hostKeysDir + "/${flakeTarget}_ssh_host_ed25519_key";
|
||||
pubKeyFile = hostKeysDir + "/${flakeTarget}_ssh_host_ed25519_key.pub";
|
||||
hasKeyForThisTarget =
|
||||
hasHostKeysDir
|
||||
&& builtins.pathExists privKeyFile
|
||||
&& builtins.pathExists pubKeyFile;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../hardware-configuration/vm/proxmox.nix
|
||||
../boot/efi.nix
|
||||
../disko/proxmox.nix
|
||||
../common/preserve-ssh-host-key.nix
|
||||
];
|
||||
|
||||
environment.etc = lib.mkIf hasKeyForThisTarget {
|
||||
"ssh/ssh_host_ed25519_key" = {
|
||||
source = privKeyFile;
|
||||
mode = "0600";
|
||||
};
|
||||
"ssh/ssh_host_ed25519_key.pub" = {
|
||||
source = pubKeyFile;
|
||||
mode = "0644";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{ config, lib, vars, ... }:
|
||||
|
||||
let
|
||||
# FQDN of the LAN NFS VIP (Pacemaker vip-lan, 192.168.2.229). Defined in
|
||||
# variables.nix as haLanNfsFqdn; using the FQDN avoids systemd-resolved
|
||||
# LLMNR quirks and survives a future VIP renumber via a DNS-only update.
|
||||
nfsServer = vars.haLanNfsFqdn;
|
||||
in
|
||||
{
|
||||
fileSystems.${vars.nfsShares.pxebootImages.mountpoint} = {
|
||||
device = "${nfsServer}:${vars.haStorageRoot}/${vars.nfsShares.pxebootImages.subpath}";
|
||||
fsType = "nfs";
|
||||
options = [
|
||||
"_netdev"
|
||||
"noatime"
|
||||
] ++ (if config.boot.isContainer
|
||||
# NFSv4 requires rpc_pipefs (sunrpc filesystem), which Proxmox LXC
|
||||
# containers block unless `features: mount=nfs` is set. Use NFSv3+nolock
|
||||
# instead: no rpc_pipefs dependency at the protocol level, and rpcbind
|
||||
# on the server handles port resolution without needing client-side
|
||||
# sunrpc infrastructure. nofail keeps boot clean if server is unreachable.
|
||||
then [ "nfsvers=3" "proto=tcp" "nolock" "nofail" ]
|
||||
else [ "nfsvers=4.2" "x-systemd.automount" ]);
|
||||
};
|
||||
|
||||
# NixOS pulls var-lib-nfs-rpc_pipefs.mount (the sunrpc filesystem) into
|
||||
# nfs-client.target for any nfs fileSystems entry. In LXC containers the
|
||||
# sunrpc mount is blocked by Proxmox's AppArmor profile, causing it to fail
|
||||
# and the activation to report an error even though our mount uses nofail.
|
||||
# Add ConditionVirtualization=!container via drop-in so systemd skips the
|
||||
# unit entirely in containers (skip = inactive, not failed), which keeps
|
||||
# nfs-client.target green and activation clean.
|
||||
systemd.units = lib.mkIf config.boot.isContainer {
|
||||
"var-lib-nfs-rpc_pipefs.mount" = {
|
||||
overrideStrategy = "asDropin";
|
||||
text = ''
|
||||
[Unit]
|
||||
ConditionVirtualization=!container
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
_:
|
||||
|
||||
{
|
||||
imports = [ ./enable-service.nix ];
|
||||
|
||||
services.tailscale = {
|
||||
# Enables the sysctl forwarding settings exit nodes/subnet routers need;
|
||||
# without this, --advertise-exit-node has no effect.
|
||||
useRoutingFeatures = "server";
|
||||
|
||||
# Lets peers reach this node directly over the tailscale UDP port
|
||||
# instead of relaying through DERP.
|
||||
openFirewall = true;
|
||||
|
||||
# extraSetFlags (tailscale set, via the always-on tailscaled-set
|
||||
# service), not extraUpFlags -- extraUpFlags is only ever applied by
|
||||
# tailscaled-autoconnect, which itself only runs when
|
||||
# services.tailscale.authKeyFile is set (nothing in this repo sets one,
|
||||
# so tailscale up is a manual, one-time operator step on every host that
|
||||
# uses this service). extraSetFlags has no such gate, so
|
||||
# --advertise-exit-node self-reapplies on every boot once the operator
|
||||
# has authenticated the node once.
|
||||
extraSetFlags = [
|
||||
"--advertise-exit-node"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
imports = [ ./enable-service.nix ];
|
||||
|
||||
services.tailscale = {
|
||||
# Enables the sysctl forwarding settings subnet routers need;
|
||||
# without this, --advertise-routes has no effect.
|
||||
useRoutingFeatures = "server";
|
||||
|
||||
# Lets peers reach this node directly over the tailscale UDP port
|
||||
# instead of relaying through DERP.
|
||||
openFirewall = true;
|
||||
};
|
||||
|
||||
# Tailscale recommends these ethtool flags on the uplink interface to get
|
||||
# full UDP GRO throughput on subnet routers (https://tailscale.com/s/ethtool-config-udp-gro).
|
||||
# The interface is derived from the default route so it works regardless of
|
||||
# what the NIC is named on a given host.
|
||||
systemd.services.tailscale-udp-gro = {
|
||||
description = "Enable UDP GRO forwarding on uplink for Tailscale subnet router";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [ pkgs.ethtool pkgs.iproute2 ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = pkgs.writeShellScript "tailscale-udp-gro" ''
|
||||
NETDEV=$(ip -o route get 8.8.8.8 | cut -f 5 -d " ")
|
||||
ethtool -K "$NETDEV" rx-udp-gro-forwarding on rx-gro-list off
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
# Run dnsmasq on the LAN interface as a forwarding-only resolver for
|
||||
# *.ts.net (Tailscale MagicDNS names). FreeIPA's bind-dyndb-ldap
|
||||
# cannot reach vars.tailscaleResolverIp directly because the DC is not a
|
||||
# Tailscale node. This host IS a Tailscale node and can reach it via
|
||||
# tailscale0, so it acts as an intermediary: FreeIPA has a conditional
|
||||
# forward zone for ts.net pointing here (vars.tailscaleRouterIp), and this
|
||||
# dnsmasq instance forwards those queries onward to Tailscale's resolver.
|
||||
#
|
||||
# Configure FreeIPA once after deploying this host:
|
||||
# kinit admin
|
||||
# ipa dnsforwardzone-add ${vars.tailnetDomain} \
|
||||
# --forwarder=${vars.tailscaleRouterIp} \
|
||||
# --forward-policy=only
|
||||
# Note: IPA refuses to shadow ts.net (a real public TLD); use the
|
||||
# tailnet-specific subdomain (vars.tailnetDomain) instead.
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
# NixOS's dnsmasq module defaults resolveLocalQueries to true, which adds
|
||||
# 127.0.0.1 to networking.nameservers and makes dnsmasq bind to
|
||||
# listen-address=127.0.0.1. This instance is not the host's local
|
||||
# resolver — it only serves IPA's conditional forwarder for tailnet names.
|
||||
# The host uses domainControllerIp directly (networking.nameservers in
|
||||
# host.nix). Without this, all host DNS goes through dnsmasq, which has
|
||||
# no upstream for general queries (no-resolv=true), breaking resolution.
|
||||
resolveLocalQueries = false;
|
||||
settings = {
|
||||
# Listen only on the LAN interface — not tailscale0 or loopback.
|
||||
# bind-interfaces prevents dnsmasq from binding to 0.0.0.0 and then
|
||||
# filtering by interface later; combined with `interface` this ensures
|
||||
# it genuinely listens only on eth0.
|
||||
bind-interfaces = true;
|
||||
interface = [ vars.lxcLanInterface ];
|
||||
|
||||
# Forward-only: no local /etc/hosts or /etc/resolv.conf reading, no
|
||||
# negative caching of NXDOMAIN for names this instance doesn't serve.
|
||||
# All ts.net queries come from FreeIPA's conditional forwarder and must
|
||||
# be answered by Tailscale's resolver.
|
||||
no-hosts = true;
|
||||
no-resolv = true;
|
||||
|
||||
# Forward *.tailnetDomain to Tailscale's internal resolver, scoped to
|
||||
# the tailnet-specific subdomain rather than all of ts.net (FreeIPA
|
||||
# refuses to shadow ts.net, a real public TLD).
|
||||
server = [ "/${vars.tailnetDomain}/${vars.tailscaleResolverIp}" ];
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedUDPPorts = [ vars.ports.dns ];
|
||||
networking.firewall.allowedTCPPorts = [ vars.ports.dns ];
|
||||
}
|
||||
@@ -37,6 +37,17 @@ source "${script_dir}/lib/nix-parallel.sh"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
# When this repo is a subdirectory of a larger git repo (e.g. a mono-repo
|
||||
# subtree), `git diff --name-only` outputs paths relative to the outer git
|
||||
# root, not this directory. Compute a prefix to strip so pattern matching
|
||||
# below works correctly regardless of nesting depth.
|
||||
_git_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "$repo_root")"
|
||||
if [[ "$repo_root" != "$_git_root" ]]; then
|
||||
_subtree_prefix="${repo_root#"$_git_root"/}/"
|
||||
else
|
||||
_subtree_prefix=""
|
||||
fi
|
||||
|
||||
full_check=false
|
||||
dry_run=false
|
||||
|
||||
@@ -120,7 +131,7 @@ if ! $full_check; then
|
||||
base_ref="$(resolve_base_ref)"
|
||||
echo
|
||||
echo "Changed-files scope: diffing against ${base_ref}"
|
||||
mapfile -t changed_files < <(git diff --name-only --diff-filter=ACMR "$base_ref" -- . | sort -u)
|
||||
mapfile -t changed_files < <(git diff --name-only --diff-filter=ACMR "$base_ref" -- . | sort -u | sed "s|^${_subtree_prefix}||")
|
||||
|
||||
if [[ ${#changed_files[@]} -eq 0 ]]; then
|
||||
echo "No changed files detected."
|
||||
|
||||
Executable
+595
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — Full lifecycle management for the Docker Swarm HA cluster.
|
||||
#
|
||||
# Provisions two NixOS Proxmox VMs (ha-docker-1, ha-docker-2) as dual-manager
|
||||
# Docker Swarm nodes sharing NFS storage from the existing HA file-server
|
||||
# cluster. Both nodes are managers so either can accept Docker API and
|
||||
# `docker stack` commands.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/docker-swarm/deploy.sh [options]
|
||||
# scripts/docker-swarm/deploy.sh --destroy [options]
|
||||
#
|
||||
# Phases (all run by default; skip any with --skip-<phase>):
|
||||
# 1. ensure-bridge Create vmbr3 (swarm cluster bridge) on the Proxmox node.
|
||||
# 2. sync-keys Generate SSH host keys for both nodes (clan vars).
|
||||
# 3. ipa-hosts Create IPA host objects + sops-encrypted keytabs.
|
||||
# 4. create-vms Build NixOS disk images and create VMs via create-proxmox-resource.sh.
|
||||
# 5. add-hardware Attach vmbr2 (storage) and vmbr3 (swarm) NICs; start VMs.
|
||||
# 6. boot-wait Wait for SSH on both LAN IPs.
|
||||
# 7. refresh-sops-keys Detect disko key drift; re-encrypt secrets; commit.
|
||||
# 8. init-swarm docker swarm init on node1; manager join on node2; label nodes.
|
||||
# 9. dns Register storage.home and swarm.home A records in FreeIPA.
|
||||
# 10. verify docker node ls; NFS mount check; swarm health.
|
||||
#
|
||||
# Options:
|
||||
# --node <host> Proxmox host (default: pve1.sweet.home)
|
||||
# --vmid1 <n> VMID for ha-docker-1 (default: 202)
|
||||
# --vmid2 <n> VMID for ha-docker-2 (default: 203)
|
||||
# --storage <pool> Proxmox storage pool (default: local-zfs)
|
||||
# --swarm-bridge <br> Bridge for Docker Swarm cluster network (default: vmbr3)
|
||||
# --storage-bridge <br> Bridge for NFS storage network (default: vmbr2)
|
||||
# --memory <MB> RAM per node (default: 4096)
|
||||
# --cores <n> vCPUs per node (default: 4)
|
||||
# --skip-ensure-bridge Skip vmbr3 creation/check
|
||||
# --skip-sync-keys Skip sync-host-keys.sh (clan vars already exist)
|
||||
# --skip-ipa-hosts Skip IPA host account creation (keytabs already exist)
|
||||
# --skip-create-vms Skip VM creation (VMs already exist)
|
||||
# --skip-add-hardware Skip NIC attachment (already attached)
|
||||
# --skip-boot-wait Skip boot/SSH wait (VMs already running)
|
||||
# --skip-refresh-sops-keys Skip sops host-key drift fix
|
||||
# --skip-init-swarm Skip swarm initialisation (already initialised)
|
||||
# --skip-dns Skip FreeIPA DNS record creation
|
||||
# --skip-verify Skip post-deploy health checks
|
||||
# --force-rebuild Pass --force-rebuild to create-proxmox-resource.sh
|
||||
# --destroy Stop and delete both VMs (skip all other phases)
|
||||
# --dry-run Print what would run without executing
|
||||
# -h|--help Show this message
|
||||
#
|
||||
# Prerequisites:
|
||||
# - SSH access to the Proxmox node as $PROXMOX_SSH_USER (wayne).
|
||||
# - sops age key in the standard location (used by sync-host-keys.sh).
|
||||
# - SSH access to domain-controller.sweet.home as $PROXMOX_SSH_USER for DNS phase.
|
||||
# - For --skip-sync-keys: clan vars already in vars/per-machine/proxmox-ha-docker-{1,2}/.
|
||||
# - For --skip-ipa-hosts: secrets/ha-docker-{1,2}.keytab already exist and are committed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
# shellcheck source=../env.sh
|
||||
source "${REPO_ROOT}/scripts/env.sh"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE="${PVE1_HOST}" # deploy.sh targets pve1 by default (authorised for this cluster)
|
||||
VMID1=202
|
||||
VMID2=203
|
||||
STORAGE="${PROXMOX_STORAGE:-local-zfs}"
|
||||
SWARM_BRIDGE="vmbr3"
|
||||
STORAGE_BRIDGE="vmbr2"
|
||||
MEMORY_MB=4096
|
||||
CORES=4
|
||||
|
||||
SKIP_ENSURE_BRIDGE=false
|
||||
SKIP_SYNC_KEYS=false
|
||||
SKIP_IPA_HOSTS=false
|
||||
SKIP_CREATE_VMS=false
|
||||
SKIP_ADD_HARDWARE=false
|
||||
SKIP_BOOT_WAIT=false
|
||||
SKIP_REFRESH_SOPS_KEYS=false
|
||||
SKIP_INIT_SWARM=false
|
||||
SKIP_DNS=false
|
||||
SKIP_VERIFY=false
|
||||
FORCE_REBUILD=false
|
||||
DESTROY=false
|
||||
DRY_RUN=false
|
||||
|
||||
# ── Variables from repo (mirrors variables.nix) ───────────────────────────────
|
||||
|
||||
NODE1_HOST="ha-docker-1"
|
||||
NODE2_HOST="ha-docker-2"
|
||||
NODE1_LAN_IP="192.168.2.230"
|
||||
NODE2_LAN_IP="192.168.2.231"
|
||||
NODE1_SWARM_IP="192.168.30.230"
|
||||
NODE2_SWARM_IP="192.168.30.231"
|
||||
NODE1_STORAGE_IP="192.168.20.230"
|
||||
NODE2_STORAGE_IP="192.168.20.231"
|
||||
SWARM_CIDR="192.168.30.0/24"
|
||||
STORAGE_CIDR="192.168.20.0/24"
|
||||
STORAGE_ZONE="storage.home"
|
||||
SWARM_ZONE="swarm.home"
|
||||
SSH_USER="${PROXMOX_SSH_USER:-wayne}"
|
||||
DC_HOST="${IPA_SERVER:-domain-controller.sweet.home}"
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \?//; p } }' "$0"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--node) NODE="$2"; shift 2 ;;
|
||||
--vmid1) VMID1="$2"; shift 2 ;;
|
||||
--vmid2) VMID2="$2"; shift 2 ;;
|
||||
--storage) STORAGE="$2"; shift 2 ;;
|
||||
--swarm-bridge) SWARM_BRIDGE="$2"; shift 2 ;;
|
||||
--storage-bridge) STORAGE_BRIDGE="$2"; shift 2 ;;
|
||||
--memory) MEMORY_MB="$2"; shift 2 ;;
|
||||
--cores) CORES="$2"; shift 2 ;;
|
||||
--skip-ensure-bridge) SKIP_ENSURE_BRIDGE=true; shift ;;
|
||||
--skip-sync-keys) SKIP_SYNC_KEYS=true; shift ;;
|
||||
--skip-ipa-hosts) SKIP_IPA_HOSTS=true; shift ;;
|
||||
--skip-create-vms) SKIP_CREATE_VMS=true; shift ;;
|
||||
--skip-add-hardware) SKIP_ADD_HARDWARE=true; shift ;;
|
||||
--skip-boot-wait) SKIP_BOOT_WAIT=true; shift ;;
|
||||
--skip-refresh-sops-keys) SKIP_REFRESH_SOPS_KEYS=true; shift ;;
|
||||
--skip-init-swarm) SKIP_INIT_SWARM=true; shift ;;
|
||||
--skip-dns) SKIP_DNS=true; shift ;;
|
||||
--skip-verify) SKIP_VERIFY=true; shift ;;
|
||||
--force-rebuild) FORCE_REBUILD=true; shift ;;
|
||||
--destroy) DESTROY=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
logn() { echo " $*"; }
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
run() {
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
pve() {
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] ssh ${SSH_USER}@${NODE} sudo $*"
|
||||
else
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
|
||||
fi
|
||||
}
|
||||
|
||||
pve_check() {
|
||||
# Read-only probe — always executes even in dry-run.
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
|
||||
}
|
||||
|
||||
SWARM_USER="nixos"
|
||||
|
||||
n1() {
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${SWARM_USER}@${NODE1_LAN_IP}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
n2() {
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${SWARM_USER}@${NODE2_LAN_IP}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
dc() {
|
||||
# Run ipa commands on domain-controller as $SSH_USER.
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] ssh ${SSH_USER}@${DC_HOST} $*"
|
||||
return 0
|
||||
fi
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${DC_HOST}" "$@"
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
local ip="$1" label="$2"
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Skipping SSH wait for ${label} (${ip})"
|
||||
return 0
|
||||
fi
|
||||
local deadline=$(( $(date +%s) + 300 ))
|
||||
log "Waiting for SSH on ${label} (${ip}) — up to 5 min..."
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=3 \
|
||||
-o BatchMode=yes "${SWARM_USER}@${ip}" true 2>/dev/null; then
|
||||
logn "${label} is up."
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
err "Timed out waiting for SSH on ${label} (${ip})"
|
||||
}
|
||||
|
||||
# ── Destroy mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
if $DESTROY; then
|
||||
log "Destroying Docker Swarm VMs (${VMID1}=${NODE1_HOST}, ${VMID2}=${NODE2_HOST}) on ${NODE}"
|
||||
for vmid in "$VMID1" "$VMID2"; do
|
||||
STATUS=$(pve "qm status ${vmid} 2>/dev/null" 2>/dev/null || true)
|
||||
if echo "$STATUS" | grep -q "running"; then
|
||||
log "Stopping VMID ${vmid}..."
|
||||
pve "qm stop ${vmid} --skiplock 1"
|
||||
sleep 5
|
||||
fi
|
||||
if $DRY_RUN || pve "qm config ${vmid} >/dev/null 2>&1"; then
|
||||
log "Deleting VMID ${vmid}..."
|
||||
run pve "qm destroy ${vmid} --purge 1"
|
||||
else
|
||||
logn "VMID ${vmid} not found — already gone."
|
||||
fi
|
||||
done
|
||||
log "Done — swarm VMs destroyed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Phase 1: Ensure swarm bridge ──────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_ENSURE_BRIDGE; then
|
||||
log "Phase 1: Ensuring swarm bridge ${SWARM_BRIDGE} on ${NODE}"
|
||||
if pve_check "test -d /sys/class/net/${SWARM_BRIDGE}" &>/dev/null; then
|
||||
logn "${SWARM_BRIDGE} already exists — skipping."
|
||||
else
|
||||
logn "Creating isolated internal bridge ${SWARM_BRIDGE} (no upstream port, ${SWARM_CIDR})"
|
||||
BRIDGE_CONF="auto ${SWARM_BRIDGE}
|
||||
iface ${SWARM_BRIDGE} inet manual
|
||||
bridge-ports none
|
||||
bridge-stp off
|
||||
bridge-fd 0"
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] Would write /etc/network/interfaces.d/${SWARM_BRIDGE}.conf and ifup it"
|
||||
else
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
|
||||
"echo '${BRIDGE_CONF}' | sudo tee /etc/network/interfaces.d/${SWARM_BRIDGE}.conf > /dev/null && sudo ifup ${SWARM_BRIDGE}"
|
||||
logn "${SWARM_BRIDGE} created and brought up."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 2: Sync host keys ───────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_SYNC_KEYS; then
|
||||
log "Phase 2: Syncing SSH host keys for both swarm targets"
|
||||
for target in proxmox-ha-docker-1 proxmox-ha-docker-2; do
|
||||
CLAN_DIR="${REPO_ROOT}/vars/per-machine/${target}/openssh"
|
||||
if [[ -d "$CLAN_DIR" ]]; then
|
||||
logn "Clan vars for ${target} already exist — skipping."
|
||||
else
|
||||
logn "Generating host keys for ${target}..."
|
||||
run bash "${REPO_ROOT}/scripts/secrets/sync-host-keys.sh" "$target"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Phase 3: IPA host accounts ────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_IPA_HOSTS; then
|
||||
log "Phase 3: Creating IPA host accounts and keytabs"
|
||||
IPA_SCRIPT="${REPO_ROOT}/scripts/ipa/create-nixos-ipa-host-account.sh"
|
||||
for host in "${NODE1_HOST}" "${NODE2_HOST}"; do
|
||||
KEYTAB="${REPO_ROOT}/secrets/${host}.keytab"
|
||||
if [[ -f "$KEYTAB" ]]; then
|
||||
logn "Keytab for ${host} already exists — skipping."
|
||||
else
|
||||
logn "Creating IPA host account and keytab for ${host}..."
|
||||
run bash "$IPA_SCRIPT" "$host"
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $DRY_RUN; then
|
||||
# Keytabs must be committed and pushed before VMs rebuild from Gitea.
|
||||
CURRENT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
logn "Committing keytabs and pushing to Gitea (branch: ${CURRENT_BRANCH})..."
|
||||
(cd "${REPO_ROOT}" && \
|
||||
git add secrets/ha-docker-1.keytab secrets/ha-docker-2.keytab .sops.yaml && \
|
||||
git commit -m "secrets(ha-docker): add IPA keytabs for ha-docker-1 and ha-docker-2" || true && \
|
||||
git push origin "${CURRENT_BRANCH}")
|
||||
logn "Pushed."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 3.5: Prepare Proxmox node for building ─────────────────────────────
|
||||
|
||||
if ! $SKIP_CREATE_VMS && ! $DRY_RUN; then
|
||||
CURRENT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
local_ssh() { ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "$*"; }
|
||||
if local_ssh "test -d /nix" &>/dev/null && ! local_ssh "test -w /nix" &>/dev/null; then
|
||||
logn "/nix exists but not writable by ${SSH_USER} — fixing ownership with sudo..."
|
||||
local_ssh "sudo chown -R ${SSH_USER} /nix"
|
||||
logn "Done."
|
||||
fi
|
||||
unset -f local_ssh
|
||||
|
||||
# Use PROXMOX_REMOTE_REPO_DIR (from env.sh) so the build path is consistent
|
||||
# with what create-proxmox-resource.sh will use. The default is
|
||||
# /home/<user>/nixos (the standalone nixos repo clone on pve1), but can be
|
||||
# overridden to e.g. /home/<user>/infrastructure/nixos when the infrastructure
|
||||
# mono-repo is checked out on pve1 instead.
|
||||
REMOTE_REPO="${PROXMOX_REMOTE_REPO_DIR:-/home/${SSH_USER}/nixos}"
|
||||
if pve_check "test -d ${REMOTE_REPO}/.git" &>/dev/null; then
|
||||
REMOTE_BRANCH=$(ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
|
||||
"cd ${REMOTE_REPO} && git rev-parse --abbrev-ref HEAD 2>/dev/null")
|
||||
if [[ "$REMOTE_BRANCH" != "$CURRENT_BRANCH" ]]; then
|
||||
logn "Remote repo (${REMOTE_REPO}) is on '${REMOTE_BRANCH}', switching to '${CURRENT_BRANCH}'..."
|
||||
if ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
|
||||
"cd ${REMOTE_REPO} && git fetch origin && git checkout '${CURRENT_BRANCH}' && git pull --ff-only" 2>&1; then
|
||||
logn "Done."
|
||||
else
|
||||
logn "WARNING: branch switch failed — proceeding anyway (create-proxmox-resource.sh will retry)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
logn "Remote repo ${REMOTE_REPO} not found on ${NODE} — create-proxmox-resource.sh will clone it."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 4: Create VMs ───────────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_CREATE_VMS; then
|
||||
log "Phase 4: Building and creating swarm VMs on ${NODE}"
|
||||
CREATE="${REPO_ROOT}/scripts/proxmox/create-proxmox-resource.sh"
|
||||
REBUILD_FLAG=""
|
||||
$FORCE_REBUILD && REBUILD_FLAG="--force-rebuild"
|
||||
|
||||
for spec in "${VMID1}:${NODE1_HOST}:proxmox-ha-docker-1" "${VMID2}:${NODE2_HOST}:proxmox-ha-docker-2"; do
|
||||
IFS=: read -r vmid host_name flake_target <<< "$spec"
|
||||
log "Creating ${flake_target} (VMID ${vmid}) on ${NODE}..."
|
||||
# --force-rebuild is always passed: create-proxmox-resource.sh only calls
|
||||
# sync_remote_host_keys (which bakes the clan-var SSH key into the disk
|
||||
# image) when it actually builds. Reusing a cached image skips that step
|
||||
# and leaves the VM unable to decrypt sops secrets on first boot.
|
||||
run bash "$CREATE" \
|
||||
--type vm \
|
||||
--host "$host_name" \
|
||||
--vmid "$vmid" \
|
||||
--node "$NODE" \
|
||||
--storage "$STORAGE" \
|
||||
--memory "$MEMORY_MB" \
|
||||
--cores "$CORES" \
|
||||
--force-rebuild
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Phase 5: Add NICs and start VMs ──────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_ADD_HARDWARE; then
|
||||
log "Phase 5: Attaching storage (${STORAGE_BRIDGE}) and swarm (${SWARM_BRIDGE}) NICs"
|
||||
for vmid in "$VMID1" "$VMID2"; do
|
||||
logn "VMID ${vmid}: stopping to add NICs..."
|
||||
pve "qm stop ${vmid} --skiplock 1 2>/dev/null; sleep 3" || true
|
||||
|
||||
logn "Adding net1 (${STORAGE_BRIDGE} — NFS storage)..."
|
||||
pve "qm set ${vmid} --net1 virtio,bridge=${STORAGE_BRIDGE},firewall=0"
|
||||
|
||||
logn "Adding net2 (${SWARM_BRIDGE} — Docker Swarm)..."
|
||||
pve "qm set ${vmid} --net2 virtio,bridge=${SWARM_BRIDGE},firewall=0"
|
||||
|
||||
logn "Starting VMID ${vmid}..."
|
||||
pve "qm start ${vmid}"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Phase 6: Wait for SSH ─────────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_BOOT_WAIT; then
|
||||
log "Phase 6: Waiting for both nodes to come up on LAN IPs"
|
||||
wait_for_ssh "$NODE1_LAN_IP" "$NODE1_HOST"
|
||||
wait_for_ssh "$NODE2_LAN_IP" "$NODE2_HOST"
|
||||
logn "Both nodes are SSHable."
|
||||
sleep 10 # let systemd finish activation
|
||||
fi
|
||||
|
||||
# ── Phase 7: Refresh sops host-key registrations ─────────────────────────────
|
||||
#
|
||||
# Disko builds raw disk images: each new VM boots with a freshly-generated SSH
|
||||
# host key, not the one pre-seeded in clan vars. Scan the running VMs; if
|
||||
# their ed25519 keys differ from the clan var, update the clan var, rewrite
|
||||
# the .sops.yaml anchor, and re-encrypt all affected sops files.
|
||||
|
||||
if ! $SKIP_REFRESH_SOPS_KEYS; then
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would scan VM host keys and refresh .sops.yaml / secrets if needed"
|
||||
else
|
||||
log "Phase 7: Refreshing sops host-key registrations (disko key drift fix)"
|
||||
SOPS_UPDATED=false
|
||||
|
||||
for spec in \
|
||||
"${NODE1_LAN_IP}:proxmox-ha-docker-1:${NODE1_HOST}" \
|
||||
"${NODE2_LAN_IP}:proxmox-ha-docker-2:${NODE2_HOST}"; do
|
||||
IFS=: read -r node_ip flake_target host_name <<< "$spec"
|
||||
CLAN_PUB="${REPO_ROOT}/vars/per-machine/${flake_target}/openssh/ssh_host_ed25519_key.pub/value"
|
||||
|
||||
logn "Scanning ed25519 host key from ${host_name} (${node_ip})..."
|
||||
RAW=$(ssh-keyscan -t ed25519 "${node_ip}" 2>/dev/null | grep -v "^#") || true
|
||||
if [[ -z "$RAW" ]]; then
|
||||
logn "WARNING: no ed25519 key returned for ${node_ip} — skipping"
|
||||
continue
|
||||
fi
|
||||
SCANNED_TYPE=$(awk '{print $2}' <<< "$RAW")
|
||||
SCANNED_KEY=$(awk '{print $3}' <<< "$RAW")
|
||||
SCANNED_PUBKEY="${SCANNED_TYPE} ${SCANNED_KEY} ${host_name}"
|
||||
|
||||
CURRENT=$(tr -d '\n' < "$CLAN_PUB" 2>/dev/null || true)
|
||||
if [[ "$SCANNED_PUBKEY" == "$CURRENT" ]]; then
|
||||
logn "${host_name}: clan var matches running key — no update needed"
|
||||
continue
|
||||
fi
|
||||
|
||||
logn "${host_name}: key drift detected — updating clan var"
|
||||
logn " old: ${CURRENT}"
|
||||
logn " new: ${SCANNED_PUBKEY}"
|
||||
echo "$SCANNED_PUBKEY" > "$CLAN_PUB"
|
||||
SOPS_UPDATED=true
|
||||
|
||||
ANCHOR="${flake_target}"
|
||||
NEW_AGE=$(echo "$SCANNED_PUBKEY" | \
|
||||
nix run --quiet --no-warn-dirty nixpkgs#ssh-to-age 2>/dev/null)
|
||||
[[ -z "$NEW_AGE" ]] && err "ssh-to-age produced no output for ${host_name}"
|
||||
logn " new age key: ${NEW_AGE}"
|
||||
sed -i "/&${ANCHOR} /s| age[a-z0-9]*$| ${NEW_AGE}|" "${REPO_ROOT}/.sops.yaml"
|
||||
done
|
||||
|
||||
if $SOPS_UPDATED; then
|
||||
logn "Running sops updatekeys on affected secrets..."
|
||||
SOPS="nix run --quiet --no-warn-dirty nixpkgs#sops --"
|
||||
(cd "${REPO_ROOT}" && \
|
||||
$SOPS updatekeys -y secrets/common.yaml && \
|
||||
$SOPS updatekeys -y secrets/ha-docker-1.keytab && \
|
||||
$SOPS updatekeys -y secrets/ha-docker-2.keytab)
|
||||
|
||||
logn "Committing refreshed host keys and re-encrypted secrets..."
|
||||
(cd "${REPO_ROOT}" && \
|
||||
git add \
|
||||
vars/per-machine/proxmox-ha-docker-1/openssh/ssh_host_ed25519_key.pub/value \
|
||||
vars/per-machine/proxmox-ha-docker-2/openssh/ssh_host_ed25519_key.pub/value \
|
||||
.sops.yaml \
|
||||
secrets/common.yaml \
|
||||
secrets/ha-docker-1.keytab \
|
||||
secrets/ha-docker-2.keytab && \
|
||||
git commit -m "secrets(ha-docker): refresh sops host-key registrations for new VM instances" || true)
|
||||
logn "Sops keys refreshed and committed."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 8: Initialise Docker Swarm ─────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_INIT_SWARM; then
|
||||
log "Phase 8: Initialising Docker Swarm"
|
||||
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would run: docker swarm init --advertise-addr ${NODE1_SWARM_IP} --data-path-addr ${NODE1_SWARM_IP} on ${NODE1_HOST}"
|
||||
logn "[dry-run] Would join ${NODE2_HOST} as manager"
|
||||
logn "[dry-run] Would label both nodes"
|
||||
else
|
||||
# Check if node1 is already a swarm manager.
|
||||
if n1 "docker info --format '{{.Swarm.LocalNodeState}}'" 2>/dev/null | grep -qx "active"; then
|
||||
logn "${NODE1_HOST} is already in a swarm — skipping init."
|
||||
else
|
||||
logn "Initialising swarm on ${NODE1_HOST} (advertise: ${NODE1_SWARM_IP})..."
|
||||
n1 "docker swarm init \
|
||||
--advertise-addr ${NODE1_SWARM_IP} \
|
||||
--data-path-addr ${NODE1_SWARM_IP}"
|
||||
logn "Swarm initialised on ${NODE1_HOST}."
|
||||
fi
|
||||
|
||||
# Check if node2 is already joined.
|
||||
if n2 "docker info --format '{{.Swarm.LocalNodeState}}'" 2>/dev/null | grep -qx "active"; then
|
||||
logn "${NODE2_HOST} is already in the swarm — skipping join."
|
||||
else
|
||||
logn "Fetching manager join token from ${NODE1_HOST}..."
|
||||
JOIN_TOKEN=$(n1 "docker swarm join-token manager -q")
|
||||
[[ -z "$JOIN_TOKEN" ]] && err "Failed to get swarm manager join token from ${NODE1_HOST}"
|
||||
|
||||
logn "Joining ${NODE2_HOST} as manager (advertise: ${NODE2_SWARM_IP})..."
|
||||
n2 "docker swarm join \
|
||||
--token ${JOIN_TOKEN} \
|
||||
--advertise-addr ${NODE2_SWARM_IP} \
|
||||
--data-path-addr ${NODE2_SWARM_IP} \
|
||||
${NODE1_SWARM_IP}:2377"
|
||||
logn "${NODE2_HOST} joined as manager."
|
||||
fi
|
||||
|
||||
# Label nodes for service placement constraints.
|
||||
logn "Labelling swarm nodes..."
|
||||
n1 "docker node update --label-add node=${NODE1_HOST} ${NODE1_HOST}" || true
|
||||
n1 "docker node update --label-add node=${NODE2_HOST} ${NODE2_HOST}" || true
|
||||
logn "Labels applied."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 9: DNS registration ─────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_DNS; then
|
||||
log "Phase 9: Registering DNS records in FreeIPA"
|
||||
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would create/verify ${SWARM_ZONE} zone and add A records"
|
||||
else
|
||||
# Check for and create the swarm.home zone if absent.
|
||||
if ! dc "ipa dnszone-show ${SWARM_ZONE}" >/dev/null 2>&1; then
|
||||
logn "Creating ${SWARM_ZONE} DNS zone..."
|
||||
dc "ipa dnszone-add ${SWARM_ZONE} \
|
||||
--name-server=${DC_HOST}. \
|
||||
--admin-email=hostmaster@${SWARM_ZONE}"
|
||||
# Reverse zone for 192.168.30.x
|
||||
dc "ipa dnszone-add 30.168.192.in-addr.arpa \
|
||||
--name-server=${DC_HOST}. \
|
||||
--admin-email=hostmaster@${SWARM_ZONE}" 2>/dev/null || \
|
||||
logn " (reverse zone 30.168.192.in-addr.arpa already exists or skipped)"
|
||||
else
|
||||
logn "${SWARM_ZONE} zone already exists."
|
||||
fi
|
||||
|
||||
# storage.home A records (zone already exists from HA cluster setup).
|
||||
for spec in "${NODE1_HOST}:${NODE1_STORAGE_IP}" "${NODE2_HOST}:${NODE2_STORAGE_IP}"; do
|
||||
IFS=: read -r hostname ip <<< "$spec"
|
||||
logn "Adding ${hostname}.${STORAGE_ZONE} → ${ip}"
|
||||
dc "ipa dnsrecord-add ${STORAGE_ZONE} ${hostname} --a-rec=${ip} --a-create-reverse" 2>/dev/null || \
|
||||
logn " (record already exists or reverse zone missing — continuing)"
|
||||
done
|
||||
|
||||
# swarm.home A records.
|
||||
for spec in "${NODE1_HOST}:${NODE1_SWARM_IP}" "${NODE2_HOST}:${NODE2_SWARM_IP}"; do
|
||||
IFS=: read -r hostname ip <<< "$spec"
|
||||
logn "Adding ${hostname}.${SWARM_ZONE} → ${ip}"
|
||||
dc "ipa dnsrecord-add ${SWARM_ZONE} ${hostname} --a-rec=${ip} --a-create-reverse" 2>/dev/null || \
|
||||
logn " (record already exists — continuing)"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 10: Verify ──────────────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_VERIFY; then
|
||||
log "Phase 10: Verifying swarm health"
|
||||
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would verify swarm node list and NFS mounts"
|
||||
else
|
||||
logn "Swarm node list:"
|
||||
n1 "docker node ls" || err "docker node ls failed on ${NODE1_HOST}"
|
||||
|
||||
logn "Checking swarm state on both nodes..."
|
||||
for spec in "${NODE1_LAN_IP}:${NODE1_HOST}" "${NODE2_LAN_IP}:${NODE2_HOST}"; do
|
||||
IFS=: read -r ip hostname <<< "$spec"
|
||||
STATE=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
|
||||
"${SWARM_USER}@${ip}" "docker info --format '{{.Swarm.LocalNodeState}}'" 2>/dev/null)
|
||||
if [[ "$STATE" != "active" ]]; then
|
||||
err "${hostname} swarm state is '${STATE}', expected 'active'"
|
||||
fi
|
||||
logn " ${hostname}: swarm=${STATE} ✓"
|
||||
done
|
||||
|
||||
logn "Checking NFS mounts on both nodes..."
|
||||
for spec in "${NODE1_LAN_IP}:${NODE1_HOST}" "${NODE2_LAN_IP}:${NODE2_HOST}"; do
|
||||
IFS=: read -r ip hostname <<< "$spec"
|
||||
NFS_OK=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
|
||||
"${SWARM_USER}@${ip}" "df -h /mnt/docker/config 2>/dev/null | grep -c nfs || echo 0" 2>/dev/null)
|
||||
if [[ "$NFS_OK" -ge 1 ]]; then
|
||||
logn " ${hostname}: /mnt/docker/config NFS mount ✓"
|
||||
else
|
||||
logn " WARNING: ${hostname}: /mnt/docker/config does not appear to be NFS-mounted"
|
||||
logn " (automount may still be pending — try: ssh nixos@${ip} 'ls /mnt/docker/config')"
|
||||
fi
|
||||
done
|
||||
|
||||
logn "Checking overlay network..."
|
||||
NETWORKS=$(n1 "docker network ls --filter driver=overlay --format '{{.Name}}'")
|
||||
if echo "$NETWORKS" | grep -q "ingress"; then
|
||||
logn " ingress overlay network present ✓"
|
||||
else
|
||||
logn " WARNING: ingress overlay network not found — swarm may not be fully initialised"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Deploy complete. Both nodes are ready for 'docker stack deploy'."
|
||||
log "Connect to either manager:"
|
||||
log " ssh nixos@${NODE1_LAN_IP} (${NODE1_HOST})"
|
||||
log " ssh nixos@${NODE2_LAN_IP} (${NODE2_HOST})"
|
||||
@@ -88,6 +88,17 @@ export NIX_CACHE_HOST
|
||||
: "${LAN_DOMAIN:=gitea.lan.ddnsgeek.com}"
|
||||
export LAN_DOMAIN
|
||||
|
||||
# Matches variables.nix's homeDomain -- the base LAN domain for service
|
||||
# subdomains, FreeIPA Kerberos realm, and host FQDNs.
|
||||
: "${HOME_DOMAIN:=sweet.home}"
|
||||
export HOME_DOMAIN
|
||||
|
||||
# Matches variables.nix's ipaServer -- the FreeIPA server hostname.
|
||||
# scripts/ipa/create-nixos-ipa-host-account.sh SSHes here to run
|
||||
# ipa host-add and ipa-getkeytab.
|
||||
: "${IPA_SERVER:=domain-controller.sweet.home}"
|
||||
export IPA_SERVER
|
||||
|
||||
# nix_extra_opts: call as a plain statement (NOT inside $(...)/<(...) --
|
||||
# that forks a subshell, and the whole point is exporting a decision back
|
||||
# into *this* shell) to populate the global NIX_OPTS array with whatever
|
||||
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# gc-hosts.sh — Run nix-collect-garbage -d on all live NixOS hosts.
|
||||
#
|
||||
# The host list is rebuilt on every run:
|
||||
# 1. This workstation (nixos) — always first
|
||||
# 2. pve1 — always second (non-NixOS Proxmox node with Nix installed)
|
||||
# 3. Every NixOS guest currently running on pve1 (discovered via pct/qm list)
|
||||
#
|
||||
# nix-cache is excluded: gc-ing the shared binary cache evicts store paths
|
||||
# that other hosts depend on for substitution.
|
||||
#
|
||||
# NixOS hosts: tries "sudo -n nix-collect-garbage -d" first (works when
|
||||
# wheelNeedsPassword = false, e.g. the HA cluster). Falls back to user-level
|
||||
# "nix-collect-garbage -d" if sudo needs a password — still collects
|
||||
# unreferenced store paths and old nixos-user profile generations, but leaves
|
||||
# old system generations in place.
|
||||
# pve1: runs "bash -l -c nix-collect-garbage -d" as the login user so
|
||||
# /etc/profile is sourced and the Nix daemon's PATH is set up automatically.
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash scripts/gc-hosts.sh [--dry-run]
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh 2>/dev/null || true
|
||||
source scripts/lib/nix-eval.sh 2>/dev/null || true
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
: "${MAX_JOBS:=8}"
|
||||
: "${NIXOS_USER:=nixos}"
|
||||
: "${PVE1_SSH_USER:=${PROXMOX_SSH_USER:-wayne}}"
|
||||
|
||||
# GC connections use BatchMode — no interactive prompts, just succeed or fail.
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10)
|
||||
# Discovery connections do NOT use BatchMode so that sudo can prompt if needed
|
||||
# (pct/qm list require root access on Proxmox).
|
||||
SSH_QUERY_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=10)
|
||||
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── build the host list ───────────────────────────────────────────────────────
|
||||
|
||||
# ORDERED_HOSTS: names in display/execution order.
|
||||
# HOST_TARGET[name]: SSH target string (user@host).
|
||||
# HOST_TYPE[name]: "nixos" (try sudo gc, fallback user) | "nix" (login-shell gc).
|
||||
declare -a ORDERED_HOSTS=()
|
||||
declare -A HOST_TARGET=()
|
||||
declare -A HOST_TYPE=()
|
||||
declare -A _SEEN_HOSTNAMES=() # dedup tracker
|
||||
|
||||
_add_host() {
|
||||
local name="$1" target="$2" type="$3"
|
||||
if [[ -n "${_SEEN_HOSTNAMES[$name]+_}" ]]; then return; fi
|
||||
_SEEN_HOSTNAMES[$name]=1
|
||||
ORDERED_HOSTS+=("$name")
|
||||
HOST_TARGET[$name]="$target"
|
||||
HOST_TYPE[$name]="$type"
|
||||
}
|
||||
|
||||
# 1. Workstation (hard-wired first)
|
||||
_add_host "nixos" "${NIXOS_USER}@nixos" "nixos"
|
||||
|
||||
# 2. pve1 (hard-wired second; non-NixOS, no system generations)
|
||||
_add_host "pve1" "${PVE1_SSH_USER}@${PVE1_HOST}" "nix"
|
||||
|
||||
# 3. Dynamically discover running NixOS guests on pve1
|
||||
#
|
||||
# create-proxmox-resource.sh names every guest after its NixOS hostname:
|
||||
# pct create ... --hostname <nixos-hostname> (LXC)
|
||||
# qm create ... --name <nixos-hostname> (VM)
|
||||
# So pct/qm list output already contains the NixOS hostname directly.
|
||||
# We validate against the flake to filter out non-NixOS guests on pve1
|
||||
# (e.g. FreeIPA, Proxmox Backup Server) that share the same Proxmox node.
|
||||
echo "Discovering running guests on ${PVE1_HOST}..."
|
||||
|
||||
# Eval the flake once to get the set of hostnames that are actually NixOS.
|
||||
# Values are NixOS hostnames (e.g. "docker"); keys are flake targets ("lxc-docker").
|
||||
nixos_hostnames=""
|
||||
nixos_hostnames="$(
|
||||
nix eval --json "${NIX_EVAL_FLAGS[@]}" .#nixosConfigurations \
|
||||
--apply 'cfgs: builtins.attrValues (builtins.mapAttrs (_: cfg: cfg.config.networking.hostName) cfgs)' \
|
||||
2>/dev/null | jq -r '.[]' | sort -u
|
||||
)" || { echo " warning: flake eval failed — non-NixOS guests will not be filtered" >&2; }
|
||||
|
||||
# SSH_QUERY_OPTS (no BatchMode) so sudo can prompt if needed for pct/qm.
|
||||
if ssh "${SSH_QUERY_OPTS[@]}" "${PVE1_SSH_USER}@${PVE1_HOST}" "true" 2>/dev/null; then
|
||||
running_guests="$(
|
||||
ssh "${SSH_QUERY_OPTS[@]}" "${PVE1_SSH_USER}@${PVE1_HOST}" bash -s <<'DISCOVER'
|
||||
sudo pct list 2>/dev/null | awk 'NR>1 && $2=="running" { print $NF }'
|
||||
sudo qm list 2>/dev/null | awk 'NR>1 && $3=="running" { print $2 }'
|
||||
DISCOVER
|
||||
)" || running_guests=""
|
||||
|
||||
while IFS= read -r hostname; do
|
||||
[[ -z "$hostname" ]] && continue
|
||||
# Exclude nix-cache.
|
||||
case "$hostname" in *nix-cache*) continue ;; esac
|
||||
# Skip if not a flake-managed NixOS host (filters non-NixOS pve1 guests).
|
||||
if [[ -n "$nixos_hostnames" ]] && ! grep -qxF "$hostname" <<< "$nixos_hostnames"; then
|
||||
continue
|
||||
fi
|
||||
# Skip if already in the list (e.g. a proxmox-gui guest whose hostname is nixos).
|
||||
if [[ -n "${_SEEN_HOSTNAMES[$hostname]+_}" ]]; then continue; fi
|
||||
|
||||
echo " + $hostname"
|
||||
_add_host "$hostname" "${NIXOS_USER}@${hostname}" "nixos"
|
||||
done <<< "$(echo "$running_guests" | sort -u)"
|
||||
else
|
||||
echo " warning: ${PVE1_HOST} unreachable — skipping dynamic host discovery" >&2
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Hosts: ${ORDERED_HOSTS[*]}"
|
||||
echo ""
|
||||
|
||||
# ── dry-run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "[dry-run] commands that would run:"
|
||||
for host in "${ORDERED_HOSTS[@]}"; do
|
||||
target="${HOST_TARGET[$host]}"
|
||||
type="${HOST_TYPE[$host]}"
|
||||
if [[ "$type" == "nixos" ]]; then
|
||||
echo " ssh ${SSH_OPTS[*]} $target 'sudo -n nix-collect-garbage -d'"
|
||||
echo " # fallback: ssh ... $target 'nix-collect-garbage -d'"
|
||||
else
|
||||
echo " ssh ${SSH_OPTS[*]} $target 'bash -l -c nix-collect-garbage -d'"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── gc worker ─────────────────────────────────────────────────────────────────
|
||||
|
||||
gc_one() {
|
||||
local host="$1" target="${HOST_TARGET[$1]}" type="${HOST_TYPE[$1]}" logfile="$2"
|
||||
|
||||
if ! ssh "${SSH_OPTS[@]}" "$target" "true" 2>>"$logfile"; then
|
||||
echo "unreachable"; return
|
||||
fi
|
||||
|
||||
if [[ "$type" == "nixos" ]]; then
|
||||
if ssh "${SSH_OPTS[@]}" "$target" "sudo -n nix-collect-garbage -d" \
|
||||
>>"$logfile" 2>>"$logfile"; then
|
||||
echo "ok(sudo)"; return
|
||||
fi
|
||||
echo "[sudo needs password — falling back to user-level gc]" >>"$logfile"
|
||||
if ssh "${SSH_OPTS[@]}" "$target" "nix-collect-garbage -d" \
|
||||
>>"$logfile" 2>>"$logfile"; then
|
||||
echo "ok(user)"; return
|
||||
fi
|
||||
else
|
||||
# Non-NixOS node: use a login shell so /etc/profile is sourced and the
|
||||
# Nix daemon's bin dir is on PATH (set up by /etc/profile.d/nix-daemon.sh
|
||||
# which the Nix installer adds to /etc/profile).
|
||||
if ssh "${SSH_OPTS[@]}" "$target" "bash -l -c 'nix-collect-garbage -d'" \
|
||||
>>"$logfile" 2>>"$logfile"; then
|
||||
echo "ok"; return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "failed:$?"
|
||||
}
|
||||
|
||||
# ── parallel execution ────────────────────────────────────────────────────────
|
||||
|
||||
echo "Running gc on ${#ORDERED_HOSTS[@]} hosts (up to ${MAX_JOBS} parallel)..."
|
||||
echo ""
|
||||
|
||||
TMPDIR_GC="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_GC"' EXIT
|
||||
|
||||
declare -A LOGS=()
|
||||
job_count=0
|
||||
|
||||
for host in "${ORDERED_HOSTS[@]}"; do
|
||||
logfile="${TMPDIR_GC}/${host}.log"
|
||||
resultfile="${TMPDIR_GC}/${host}.result"
|
||||
LOGS[$host]="$logfile"
|
||||
: > "$logfile"
|
||||
|
||||
( result="$(gc_one "$host" "$logfile")"; echo "$result" > "$resultfile" ) &
|
||||
|
||||
(( job_count++ )) || true
|
||||
if [[ "$job_count" -ge "$MAX_JOBS" ]]; then
|
||||
wait -n 2>/dev/null || wait
|
||||
(( job_count-- )) || true
|
||||
fi
|
||||
done
|
||||
|
||||
wait
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Results:"
|
||||
echo "──────────────────────────────"
|
||||
|
||||
ok_hosts=()
|
||||
warn_hosts=()
|
||||
fail_hosts=()
|
||||
|
||||
for host in "${ORDERED_HOSTS[@]}"; do
|
||||
result="$(cat "${TMPDIR_GC}/${host}.result" 2>/dev/null || echo "failed:missing")"
|
||||
case "$result" in
|
||||
ok|"ok(sudo)"|"ok(user)")
|
||||
printf " %-22s %s\n" "$host" "$result"
|
||||
ok_hosts+=("$host") ;;
|
||||
unreachable)
|
||||
printf " %-22s UNREACHABLE\n" "$host"
|
||||
warn_hosts+=("$host") ;;
|
||||
*)
|
||||
printf " %-22s FAILED (%s)\n" "$host" "$result"
|
||||
fail_hosts+=("$host") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " ${#ok_hosts[@]} succeeded, ${#warn_hosts[@]} unreachable, ${#fail_hosts[@]} failed"
|
||||
|
||||
for host in "${warn_hosts[@]+"${warn_hosts[@]}"}" "${fail_hosts[@]+"${fail_hosts[@]}"}"; do
|
||||
logfile="${LOGS[$host]}"
|
||||
if [[ -s "$logfile" ]]; then
|
||||
echo ""
|
||||
echo "── $host ──"
|
||||
cat "$logfile"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
[[ "${#fail_hosts[@]}" -eq 0 ]]
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
# acceptance-tests.sh — HA cluster acceptance tests (T1–T7)
|
||||
#
|
||||
# Run from a host with SSH access to both HA nodes (or from node1 itself).
|
||||
# All 7 tests must pass before considering the cluster production-ready.
|
||||
# Test values below must match variables.nix haServer* values.
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
# All values override-able via environment variables; defaults match variables.nix.
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
||||
VIP="${VIP:-192.168.20.229}" # vars.haServerVip
|
||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
||||
ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RESULTS=()
|
||||
|
||||
# Use PASS=$((PASS+1)) instead of ((PASS++)) — the latter evaluates to 0 when
|
||||
# PASS=0, which triggers set -e and kills the script after the very first PASS.
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS+1)); RESULTS+=("PASS $1"); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL+1)); RESULTS+=("FAIL $1"); }
|
||||
|
||||
HA_USER="nixos"
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Cluster Acceptance Tests — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Pre-flight: DRBD sync must be complete ────────────────────────────────
|
||||
# Tests that check disk state, XFS mount, and iSCSI will fail or give false
|
||||
# results while the initial full sync is in progress. Block until done.
|
||||
echo ""
|
||||
echo "Pre-flight: verifying DRBD sync is complete..."
|
||||
DRBD_PREFLIGHT=$(n1 "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if ! echo "$DRBD_PREFLIGHT" | grep -q "^UpToDate/UpToDate$"; then
|
||||
echo ""
|
||||
echo " ERROR: DRBD initial sync not complete."
|
||||
echo " Current dstate on $NODE1: $DRBD_PREFLIGHT"
|
||||
echo ""
|
||||
echo " Monitor progress:"
|
||||
echo " ssh nixos@$NODE1_IP 'sudo watch -n3 cat /proc/drbd'"
|
||||
echo ""
|
||||
echo " Re-run this script once dstate shows UpToDate/UpToDate."
|
||||
exit 1
|
||||
fi
|
||||
echo " dstate: $DRBD_PREFLIGHT — ready."
|
||||
|
||||
# ── Detect Active/Standby nodes ────────────────────────────────────────────
|
||||
# Use crm_mon to detect which node holds the Promoted (Primary) DRBD resource.
|
||||
# Pacemaker is authoritative; DRBD role can briefly read as Secondary while
|
||||
# Pacemaker is mid-transition, giving a false Active/Standby swap.
|
||||
# Wait up to 90 s for Pacemaker to settle before giving up.
|
||||
echo ""
|
||||
echo "Detecting Active/Standby nodes (waiting for Pacemaker to settle)..."
|
||||
ACTIVE_NODE=""
|
||||
for i in $(seq 1 30); do
|
||||
# crm_mon -1 output contains "Promoted: [ <node> ]" for the DRBD master.
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || n2 "crm_mon -1" 2>/dev/null || true)
|
||||
# crm_mon 2.x formats Promoted lines as " * Promoted: [ node ]" — the * bullet
|
||||
# means ^\s*(Promoted|Masters): never matches; filter Unpromoted first instead.
|
||||
ACTIVE_NODE=$(echo "$CRM_OUT" | grep -v 'Unpromoted\|Unmanaged' | grep -E '(Promoted|Masters):' | grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
[[ -n "$ACTIVE_NODE" ]] && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo " WARNING: could not determine Active node from crm_mon after 90 s — defaulting to $NODE1"
|
||||
ACTIVE_NODE="$NODE1"
|
||||
fi
|
||||
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
ACTIVE_IP="$NODE1_IP"
|
||||
STANDBY_NODE="$NODE2"; STANDBY_IP="$NODE2_IP"
|
||||
na() { n1 "$@"; }
|
||||
ns() { n2 "$@"; }
|
||||
else
|
||||
ACTIVE_IP="$NODE2_IP"
|
||||
STANDBY_NODE="$NODE1"; STANDBY_IP="$NODE1_IP"
|
||||
na() { n2 "$@"; }
|
||||
ns() { n1 "$@"; }
|
||||
fi
|
||||
echo " Active: $ACTIVE_NODE ($ACTIVE_IP)"
|
||||
echo " Standby: $STANDBY_NODE ($STANDBY_IP)"
|
||||
|
||||
# ── T1: Corosync quorum established ──────────────────────────────────────
|
||||
echo ""
|
||||
echo "[T1] Corosync quorum"
|
||||
if na "corosync-quorumtool -s" 2>/dev/null | grep -q "Quorate:.*Yes"; then
|
||||
pass "cluster has quorum"
|
||||
else
|
||||
fail "cluster does not have quorum — check corosync on both nodes"
|
||||
fi
|
||||
|
||||
# ── T2: DRBD Primary on Active node, Secondary on Standby ────────────────
|
||||
echo ""
|
||||
echo "[T2] DRBD roles"
|
||||
DRBD_ROLE=$(na "drbdadm role ha-data" 2>/dev/null || echo "unknown")
|
||||
if [[ "$DRBD_ROLE" == "Primary/Secondary" || "$DRBD_ROLE" == "Primary" ]]; then
|
||||
pass "DRBD Primary on $ACTIVE_NODE ($DRBD_ROLE)"
|
||||
else
|
||||
fail "unexpected DRBD role on $ACTIVE_NODE: $DRBD_ROLE (expected Primary/Secondary)"
|
||||
fi
|
||||
|
||||
DRBD_DSTATE=$(na "drbdadm dstate ha-data" 2>/dev/null || echo "unknown")
|
||||
if echo "$DRBD_DSTATE" | grep -q "UpToDate"; then
|
||||
pass "DRBD disk state UpToDate ($DRBD_DSTATE)"
|
||||
else
|
||||
fail "DRBD disk not UpToDate: $DRBD_DSTATE"
|
||||
fi
|
||||
|
||||
# ── T3: XFS mounted at haStorageRoot on the Active node ──────────────────
|
||||
echo ""
|
||||
echo "[T3] XFS mount"
|
||||
if na "mountpoint -q '${XFS_MOUNT}'" 2>/dev/null; then
|
||||
pass "XFS mounted at ${XFS_MOUNT} on $ACTIVE_NODE"
|
||||
else
|
||||
fail "XFS not mounted at ${XFS_MOUNT} on $ACTIVE_NODE"
|
||||
fi
|
||||
|
||||
if ns "mountpoint -q '${XFS_MOUNT}'" 2>/dev/null; then
|
||||
fail "XFS unexpectedly mounted on $STANDBY_NODE (should only be on Active node)"
|
||||
else
|
||||
pass "XFS not mounted on $STANDBY_NODE (correct — Standby)"
|
||||
fi
|
||||
|
||||
# ── T4: iSCSI target visible on Active node ───────────────────────────────
|
||||
echo ""
|
||||
echo "[T4] iSCSI target"
|
||||
IQN_COUNT=$(na "bash -c 'ls /sys/kernel/config/target/iscsi/ 2>/dev/null | grep -c iqn || true'" 2>/dev/null || echo "0")
|
||||
if [[ "$IQN_COUNT" -ge 1 ]]; then
|
||||
pass "iSCSI IQN active on $ACTIVE_NODE ($IQN_COUNT target(s))"
|
||||
else
|
||||
fail "no iSCSI IQN active on $ACTIVE_NODE"
|
||||
fi
|
||||
|
||||
# iSCSI port reachable from Standby node via VIP.
|
||||
# Use bash TCP probe (no iscsiadm needed — just checks port 3260 is open).
|
||||
if ns "bash -c 'echo >/dev/tcp/${VIP}/3260' 2>/dev/null"; then
|
||||
pass "iSCSI port 3260 reachable from $STANDBY_NODE via VIP ${VIP}"
|
||||
else
|
||||
fail "iSCSI port 3260 not reachable from $STANDBY_NODE via ${VIP}"
|
||||
fi
|
||||
|
||||
# ── T5: Failover — standby Active node, verify resources move to Standby ──
|
||||
echo ""
|
||||
echo "[T5] Failover (standby $ACTIVE_NODE)"
|
||||
ACTIVE_CRMD_NAME=$(na "crm_node -n" 2>/dev/null || echo "")
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v on" 2>/dev/null || true
|
||||
echo " Waiting up to 120 s for resources to move to $STANDBY_NODE..."
|
||||
MOVED=false
|
||||
for i in $(seq 1 120); do
|
||||
if ns "mountpoint -q '${XFS_MOUNT}'" 2>/dev/null; then
|
||||
MOVED=true
|
||||
echo " Resources moved in ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if $MOVED; then
|
||||
pass "XFS mounted on $STANDBY_NODE after failover"
|
||||
IQN_ON_STANDBY=$(ns "bash -c 'ls /sys/kernel/config/target/iscsi/ 2>/dev/null | grep -c iqn || true'" 2>/dev/null || echo "0")
|
||||
[[ "$IQN_ON_STANDBY" -ge 1 ]] \
|
||||
&& pass "iSCSI target active on $STANDBY_NODE after failover" \
|
||||
|| fail "iSCSI target NOT active on $STANDBY_NODE after failover"
|
||||
else
|
||||
fail "XFS did not mount on $STANDBY_NODE within 120 s — failover incomplete"
|
||||
fi
|
||||
|
||||
# ── T6: Data integrity — file written post-failover readable ─────────────
|
||||
echo ""
|
||||
echo "[T6] Data integrity"
|
||||
# Write a test file on the new Active (former Standby) and verify it.
|
||||
# Use `echo | sudo tee` for the write: "echo ... > file" via bash -c has the
|
||||
# redirect interpreted by the remote nixos shell (not sudo), so the file open
|
||||
# runs as nixos and fails with EACCES on the root-owned XFS mount. Piping
|
||||
# through sudo tee lets tee (running as root) open the file instead.
|
||||
TEST_FILE="${XFS_MOUNT}/.acceptance-test-$$"
|
||||
TEST_CONTENT="ha-acceptance-test-$(date +%s)"
|
||||
echo "${TEST_CONTENT}" | ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${STANDBY_IP}" sudo tee "${TEST_FILE}" > /dev/null 2>/dev/null || true
|
||||
READBACK=$(ns cat "${TEST_FILE}" 2>/dev/null || echo "")
|
||||
if [[ "$READBACK" == "$TEST_CONTENT" ]]; then
|
||||
pass "test file written and read back correctly on $STANDBY_NODE"
|
||||
else
|
||||
fail "data integrity check failed (wrote: '$TEST_CONTENT', read: '$READBACK')"
|
||||
fi
|
||||
ns rm -f "${TEST_FILE}" 2>/dev/null || true
|
||||
|
||||
# ── T7: Node rejoin — un-standby original Active, verify cluster is healthy ─
|
||||
echo ""
|
||||
echo "[T7] Node rejoin"
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v off" 2>/dev/null || true
|
||||
na "crm_resource --cleanup" 2>/dev/null || true
|
||||
sleep 5
|
||||
|
||||
if na "corosync-quorumtool -s 2>/dev/null | grep -q 'Quorate:.*Yes'"; then
|
||||
pass "$ACTIVE_NODE rejoined — cluster has quorum"
|
||||
else
|
||||
fail "$ACTIVE_NODE did not rejoin with quorum"
|
||||
fi
|
||||
|
||||
DRBD_ROLE_AFTER=$(na "drbdadm role ha-data" 2>/dev/null || echo "unknown")
|
||||
if echo "$DRBD_ROLE_AFTER" | grep -q "Secondary"; then
|
||||
pass "$ACTIVE_NODE is DRBD Secondary after rejoin ($DRBD_ROLE_AFTER)"
|
||||
else
|
||||
fail "unexpected DRBD role on $ACTIVE_NODE after rejoin: $DRBD_ROLE_AFTER"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " Results: ${PASS} PASS, ${FAIL} FAIL"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
for r in "${RESULTS[@]}"; do echo " $r"; done
|
||||
echo ""
|
||||
|
||||
if [[ "$FAIL" -eq 0 ]]; then
|
||||
echo "ALL PASS — cluster is production-ready."
|
||||
exit 0
|
||||
else
|
||||
echo "SOME TESTS FAILED — investigate before deploying."
|
||||
exit 1
|
||||
fi
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# cluster-enable-stonith.sh — enable STONITH fence agent after the fence SSH
|
||||
# key is deployed to both nodes and authorised on the Proxmox host.
|
||||
#
|
||||
# Run from ha-server-1 as root AFTER:
|
||||
# - /etc/pacemaker/fence_pve_ssh exists on both nodes (chmod +x)
|
||||
# (copy from scripts/ha/fence-pve-ssh.py)
|
||||
# - /etc/fence-pve-ssh-key (SSH private key) exists on both nodes
|
||||
# - The corresponding public key is in authorized_keys on PVE_HOST
|
||||
# - VMID_NODE1 / VMID_NODE2 filled in below
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
NODE1="ha-server-1"
|
||||
NODE2="ha-server-2"
|
||||
VMID_NODE1="" # FILL IN: Proxmox VMID for ha-server-1
|
||||
VMID_NODE2="" # FILL IN: Proxmox VMID for ha-server-2
|
||||
PVE_HOST="pve1.sweet.home"
|
||||
PVE_USER="wayne"
|
||||
FENCE_KEY="/etc/fence-pve-ssh-key"
|
||||
FENCE_SCRIPT="/etc/pacemaker/fence_pve_ssh"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "[stonith-setup] $*"; }
|
||||
die() { echo "[stonith-setup] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
[[ $(id -u) -eq 0 ]] || die "must run as root"
|
||||
[[ -n "$VMID_NODE1" ]] || die "VMID_NODE1 not set — edit this script"
|
||||
[[ -n "$VMID_NODE2" ]] || die "VMID_NODE2 not set — edit this script"
|
||||
[[ -f "$FENCE_KEY" ]] || die "fence key not found at $FENCE_KEY"
|
||||
[[ -f "$FENCE_SCRIPT" ]] || die "fence script not found at $FENCE_SCRIPT"
|
||||
|
||||
log "Verifying fence agent can reach ${PVE_HOST}..."
|
||||
ssh -i "$FENCE_KEY" -o BatchMode=yes -o ConnectTimeout=10 \
|
||||
-o StrictHostKeyChecking=no "${PVE_USER}@${PVE_HOST}" \
|
||||
"sudo /usr/sbin/qm list" &>/dev/null \
|
||||
|| die "Cannot SSH to ${PVE_USER}@${PVE_HOST} — check authorized_keys and sudo"
|
||||
log "Fence agent SSH connectivity confirmed"
|
||||
|
||||
log "Creating Pacemaker STONITH resources..."
|
||||
cibadmin --create --scope resources --xml-text "
|
||||
<primitive id=\"stonith-${NODE1}\" class=\"stonith\" type=\"external/fence_pve_ssh\">
|
||||
<instance_attributes id=\"stonith-${NODE1}-attrs\">
|
||||
<nvpair id=\"stonith-${NODE1}-plug\" name=\"plug\" value=\"${NODE1}\"/>
|
||||
<nvpair id=\"stonith-${NODE1}-pve-host\" name=\"pve_host\" value=\"${PVE_HOST}\"/>
|
||||
<nvpair id=\"stonith-${NODE1}-pve-user\" name=\"pve_user\" value=\"${PVE_USER}\"/>
|
||||
<nvpair id=\"stonith-${NODE1}-key-file\" name=\"key_file\" value=\"${FENCE_KEY}\"/>
|
||||
<nvpair id=\"stonith-${NODE1}-vmid1\" name=\"vmid_node1\" value=\"${VMID_NODE1}\"/>
|
||||
<nvpair id=\"stonith-${NODE1}-vmid2\" name=\"vmid_node2\" value=\"${VMID_NODE2}\"/>
|
||||
<nvpair id=\"stonith-${NODE1}-host-list\" name=\"pcmk_host_list\" value=\"${NODE1}\"/>
|
||||
</instance_attributes>
|
||||
<operations>
|
||||
<op id=\"stonith-${NODE1}-monitor\" name=\"monitor\" interval=\"30s\" timeout=\"30s\"/>
|
||||
</operations>
|
||||
</primitive>
|
||||
" 2>/dev/null || true
|
||||
|
||||
cibadmin --create --scope resources --xml-text "
|
||||
<primitive id=\"stonith-${NODE2}\" class=\"stonith\" type=\"external/fence_pve_ssh\">
|
||||
<instance_attributes id=\"stonith-${NODE2}-attrs\">
|
||||
<nvpair id=\"stonith-${NODE2}-plug\" name=\"plug\" value=\"${NODE2}\"/>
|
||||
<nvpair id=\"stonith-${NODE2}-pve-host\" name=\"pve_host\" value=\"${PVE_HOST}\"/>
|
||||
<nvpair id=\"stonith-${NODE2}-pve-user\" name=\"pve_user\" value=\"${PVE_USER}\"/>
|
||||
<nvpair id=\"stonith-${NODE2}-key-file\" name=\"key_file\" value=\"${FENCE_KEY}\"/>
|
||||
<nvpair id=\"stonith-${NODE2}-vmid1\" name=\"vmid_node1\" value=\"${VMID_NODE1}\"/>
|
||||
<nvpair id=\"stonith-${NODE2}-vmid2\" name=\"vmid_node2\" value=\"${VMID_NODE2}\"/>
|
||||
<nvpair id=\"stonith-${NODE2}-host-list\" name=\"pcmk_host_list\" value=\"${NODE2}\"/>
|
||||
</instance_attributes>
|
||||
<operations>
|
||||
<op id=\"stonith-${NODE2}-monitor\" name=\"monitor\" interval=\"30s\" timeout=\"30s\"/>
|
||||
</operations>
|
||||
</primitive>
|
||||
" 2>/dev/null || true
|
||||
|
||||
log "Enabling STONITH and restoring quorum policy..."
|
||||
crm_attribute -t crm_config -n stonith-enabled -v true
|
||||
crm_attribute -t crm_config -n no-quorum-policy -v stop
|
||||
|
||||
log "DRBD fencing mode must also be updated to resource-only (already the"
|
||||
log "default in cluster-config.nix; confirm with: cat /etc/drbd.d/ha-data.conf)"
|
||||
|
||||
log "Testing fence agent..."
|
||||
stonith_admin --list-devices && log "Fence devices listed successfully." \
|
||||
|| warn "stonith_admin --list-devices failed — check config"
|
||||
|
||||
log "STONITH enabled. Cluster is now fully HA."
|
||||
Executable
+483
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env bash
|
||||
# cluster-init.sh — one-time HA cluster initialisation script
|
||||
#
|
||||
# Run ONCE from ha-server-1 as root AFTER both VMs are booted and have SSH
|
||||
# access. It:
|
||||
# 1. Generates and distributes the corosync authkey
|
||||
# 2. Waits for corosync quorum and pacemaker
|
||||
# 3. Initialises DRBD metadata, promotes node1 to primary
|
||||
# 4. Creates XFS on /dev/drbd0 and mounts it
|
||||
# 5. Creates the directory tree and iSCSI LUN backing file
|
||||
# 6. Configures LIO iSCSI target (file-backed LUN)
|
||||
# 7. Configures Pacemaker resources: DRBD → XFS → iSCSI → NFS → VIP
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Both VMs booted with the ha-server config (nixos-rebuild done)
|
||||
# - SSH key access from node1 to root@NODE2_IP
|
||||
# - VMID_NODE1 / VMID_NODE2 filled in below (needed for STONITH setup;
|
||||
# cluster starts without STONITH, which you enable separately via
|
||||
# scripts/ha/cluster-enable-stonith.sh)
|
||||
# - Run as root on ha-server-1
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
# All values override-able via environment variables; defaults match variables.nix.
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
||||
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
|
||||
ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn
|
||||
ISCSI_LUN_FILE="${XFS_MOUNT}/iscsi-lun.img"
|
||||
ISCSI_LUN_SIZE="10G"
|
||||
DRBD_DEVICE="/dev/drbd0"
|
||||
# DRBD backing disk — by-id path that resolves correctly on both nodes
|
||||
# regardless of whether the OS-level name is sda or sdb (Proxmox VM disk
|
||||
# ordering is not guaranteed). Matches haServerDrbdDisk in variables.nix.
|
||||
# Override DRBD_DISK if your hardware uses a different controller/slot path.
|
||||
DRBD_DISK="${DRBD_DISK:-/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1}"
|
||||
VMID_NODE1="${VMID_NODE1:-}" # set by deploy.sh; needed for STONITH
|
||||
VMID_NODE2="${VMID_NODE2:-}"
|
||||
PVE_HOST="${PVE_HOST:-pve1.sweet.home}"
|
||||
PVE_USER="${PVE_USER:-wayne}"
|
||||
# Inter-node SSH: HA_USER is the user to SSH as on NODE2; HA_KEY is the private
|
||||
# key to use. Default is root-to-root (no key arg). deploy.sh sets HA_USER=nixos
|
||||
# and HA_KEY=/tmp/cluster-init-key so the script works even when root-to-root SSH
|
||||
# is not available.
|
||||
HA_USER="${HA_USER:-root}"
|
||||
HA_KEY="${HA_KEY:-}"
|
||||
|
||||
# NFS dataset subdirectories to create under XFS_MOUNT.
|
||||
# Must mirror vars.nfsShares subpath values in variables.nix.
|
||||
NFS_SUBDIRS=(
|
||||
"docker/config"
|
||||
"docker/volumes"
|
||||
"docker/databases"
|
||||
"docker/nextcloud-data"
|
||||
"raspi/volumes"
|
||||
"proxmox/iso"
|
||||
"proxmox/lxc"
|
||||
"pxe-boot/images"
|
||||
)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "[cluster-init] $*"; }
|
||||
die() { echo "[cluster-init] ERROR: $*" >&2; exit 1; }
|
||||
warn() { echo "[cluster-init] WARNING: $*" >&2; }
|
||||
|
||||
[[ $(id -u) -eq 0 ]] || die "must run as root"
|
||||
[[ "$(hostname)" == "$NODE1" ]] || die "must run on $NODE1"
|
||||
|
||||
# NixOS may not include xfsprogs in root's PATH even when it's in the store.
|
||||
# If mkfs.xfs is missing, search the Nix store for it.
|
||||
if ! command -v mkfs.xfs &>/dev/null; then
|
||||
_xfs_bin=$(find /nix/store -maxdepth 3 -name mkfs.xfs 2>/dev/null | head -1 | xargs dirname 2>/dev/null || true)
|
||||
[[ -n "$_xfs_bin" ]] && export PATH="$_xfs_bin:$PATH" \
|
||||
|| die "mkfs.xfs not found — add xfsprogs to ha-server.nix environment.systemPackages and rebuild"
|
||||
fi
|
||||
|
||||
# drbdmeta lives alongside drbdadm but may not be in PATH when run via sudo.
|
||||
if ! command -v drbdmeta &>/dev/null; then
|
||||
_drbd_bin=$(dirname "$(command -v drbdadm)" 2>/dev/null || true)
|
||||
[[ -n "$_drbd_bin" ]] && export PATH="$_drbd_bin:$PATH" \
|
||||
|| die "drbdmeta not found — is drbd-utils in ha-server environment.systemPackages?"
|
||||
fi
|
||||
|
||||
# Portable 16-hex-char UUID generator (no openssl required).
|
||||
_rand_uuid() {
|
||||
cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-16 | tr '[:lower:]' '[:upper:]'
|
||||
}
|
||||
|
||||
# Inter-node SSH/SCP helpers — abstract over root-to-root vs nixos+sudo.
|
||||
_SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
[[ -n "$HA_KEY" ]] && _SSH_OPTS="-i $HA_KEY $_SSH_OPTS"
|
||||
if [[ "$HA_USER" == "root" ]]; then
|
||||
n2_ssh() { ssh $_SSH_OPTS "root@${NODE2_IP}" "$@"; }
|
||||
n2_scp() { scp $_SSH_OPTS "$1" "root@${NODE2_IP}:$2"; }
|
||||
else
|
||||
# Non-root user with passwordless sudo; wrap each command with sudo.
|
||||
n2_ssh() { ssh $_SSH_OPTS "${HA_USER}@${NODE2_IP}" sudo "$@"; }
|
||||
n2_scp() {
|
||||
# SCP to a tmp path, then sudo-move to the real destination as the remote user.
|
||||
local src="$1" dst="$2"
|
||||
local tmp="/tmp/_cluster_init_scp_$$"
|
||||
scp $_SSH_OPTS "$src" "${HA_USER}@${NODE2_IP}:${tmp}"
|
||||
ssh $_SSH_OPTS "${HA_USER}@${NODE2_IP}" sudo mv "${tmp}" "${dst}"
|
||||
}
|
||||
fi
|
||||
|
||||
# ── 0. Corosync authkey ───────────────────────────────────────────────────
|
||||
AUTHKEY="/etc/corosync/authkey"
|
||||
mkdir -p /etc/corosync
|
||||
if [[ ! -f "$AUTHKEY" ]]; then
|
||||
log "Generating corosync authkey..."
|
||||
corosync-keygen -k "$AUTHKEY"
|
||||
chmod 0400 "$AUTHKEY"
|
||||
fi
|
||||
log "Distributing authkey to $NODE2..."
|
||||
n2_ssh "mkdir -p /etc/corosync"
|
||||
n2_scp "$AUTHKEY" "$AUTHKEY"
|
||||
n2_ssh "chmod 0400 '${AUTHKEY}'"
|
||||
|
||||
log "Restarting corosync and pacemaker on both nodes..."
|
||||
systemctl restart corosync
|
||||
n2_ssh "systemctl restart corosync"
|
||||
sleep 3
|
||||
|
||||
log "Starting pacemaker on both nodes (may have failed at boot before authkey was placed)..."
|
||||
systemctl start pacemaker 2>/dev/null || systemctl restart pacemaker 2>/dev/null || true
|
||||
n2_ssh "systemctl start pacemaker 2>/dev/null || systemctl restart pacemaker 2>/dev/null || true"
|
||||
sleep 2
|
||||
|
||||
# ── 1. Corosync quorum ────────────────────────────────────────────────────
|
||||
log "Waiting for corosync quorum..."
|
||||
for i in $(seq 1 30); do
|
||||
if corosync-quorumtool -s 2>/dev/null | grep -q 'Quorate:.*Yes'; then
|
||||
log "Quorum established"
|
||||
break
|
||||
fi
|
||||
[[ $i -eq 30 ]] && die "corosync quorum not established after 60 s"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "Waiting for pacemaker..."
|
||||
for i in $(seq 1 30); do
|
||||
if crm_mon -1 &>/dev/null; then
|
||||
log "Pacemaker running"
|
||||
break
|
||||
fi
|
||||
[[ $i -eq 30 ]] && die "pacemaker not running after 60 s"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ── 2. DRBD initialisation ────────────────────────────────────────────────
|
||||
# Put both nodes in Pacemaker standby first so it stops managed resources
|
||||
# cleanly, then enable maintenance-mode so Pacemaker's monitor operations are
|
||||
# suspended. Without maintenance-mode, Pacemaker keeps monitoring: when it
|
||||
# sees DRBD Primary on a standby node (that it didn't start), it triggers a
|
||||
# stop action — killing the initial sync after ~10 s. Maintenance-mode
|
||||
# disables all start/stop/monitor actions for the duration of the sync; it is
|
||||
# cleared after UpToDate/UpToDate is confirmed.
|
||||
log "Setting both nodes to Pacemaker standby for DRBD metadata init..."
|
||||
crm_standby -N "$NODE1" -v on 2>/dev/null || true
|
||||
crm_standby -N "$NODE2" -v on 2>/dev/null || true
|
||||
|
||||
# Wait for Pacemaker to actually stop DRBD (if it was managing it).
|
||||
log "Waiting for DRBD to stop under Pacemaker control..."
|
||||
for i in $(seq 1 30); do
|
||||
n1_role=$(drbdadm role ha-data 2>/dev/null || echo "Unconfigured")
|
||||
n2_role=$(n2_ssh "drbdadm role ha-data 2>/dev/null" 2>/dev/null || echo "Unconfigured")
|
||||
if [[ "$n1_role" == "Unconfigured" ]] && [[ "$n2_role" == "Unconfigured" ]]; then
|
||||
log "DRBD stopped on both nodes"
|
||||
break
|
||||
fi
|
||||
[[ $i -eq 30 ]] && warn "DRBD still active after 60s standby — forcing down anyway"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "Enabling Pacemaker maintenance-mode (suspends monitor/start/stop during sync)..."
|
||||
crm_attribute -t crm_config -n maintenance-mode -v true 2>/dev/null || true
|
||||
|
||||
log "Detaching DRBD on $NODE1 (belt-and-suspenders after standby)..."
|
||||
drbdadm down ha-data 2>/dev/null || true
|
||||
log "Detaching DRBD on $NODE2..."
|
||||
n2_ssh "drbdadm down ha-data 2>/dev/null || true"
|
||||
sleep 2
|
||||
|
||||
# Ensure /etc/drbd.conf on both nodes points to DRBD_DISK (the stable by-id
|
||||
# path). VMs built before this fix may have /dev/sda or /dev/sdb hardcoded.
|
||||
# NixOS makes /etc/drbd.conf a symlink into the read-only Nix store, so
|
||||
# sed -i on the symlink target would fail — we break the symlink first with
|
||||
# cp --remove-destination, creating a regular writable copy.
|
||||
# Rebuild+redeploy (--force-rebuild) to make this permanent.
|
||||
_PATCH_DRBD=$(mktemp)
|
||||
cat > "$_PATCH_DRBD" << 'PATCHEOF'
|
||||
#!/bin/bash
|
||||
WANT="$1"
|
||||
conf=/etc/drbd.conf
|
||||
if [[ -L "$conf" ]]; then
|
||||
cp --remove-destination "$(readlink -f "$conf")" "$conf"
|
||||
fi
|
||||
cur=$(drbdadm sh-ll-dev ha-data 2>/dev/null | head -1 || true)
|
||||
if [[ -n "$cur" && "$cur" != "$WANT" ]]; then
|
||||
echo "[cluster-init] WARNING: patching $conf: $cur → $WANT (rebuild to make permanent)"
|
||||
sed -i "s,${cur},${WANT},g" "$conf"
|
||||
fi
|
||||
PATCHEOF
|
||||
chmod +x "$_PATCH_DRBD"
|
||||
bash "$_PATCH_DRBD" "$DRBD_DISK"
|
||||
n2_scp "$_PATCH_DRBD" "/tmp/patch-drbd-disk.sh"
|
||||
n2_ssh "bash /tmp/patch-drbd-disk.sh ${DRBD_DISK}"
|
||||
n2_ssh "rm -f /tmp/patch-drbd-disk.sh"
|
||||
rm -f "$_PATCH_DRBD"
|
||||
|
||||
log "Initialising DRBD metadata on $NODE1..."
|
||||
# Use drbdmeta --force directly for BOTH create-md and write-dev-uuid.
|
||||
# drbdadm create-md --force passes --force to drbdmeta create-md but NOT to
|
||||
# the write-dev-uuid sub-call it makes internally, so write-dev-uuid fails when
|
||||
# the backing disk is still busy and stdin is not a TTY:
|
||||
# "stdin not a TTY, not waiting for confirmation" → exit 20.
|
||||
# Calling drbdmeta --force directly bypasses the exclusive-open confirmation on
|
||||
# both steps without needing a TTY, regardless of whether the device is busy.
|
||||
# Skip metadata creation only if DRBD is UP and fully synced (UpToDate/UpToDate).
|
||||
# When the resource is down, drbdadm dstate reads metadata and returns just
|
||||
# "UpToDate" (no slash) — that must not be treated as "already synced".
|
||||
# Mismatched UUIDs from an interrupted sync cause instant WFConnection→StandAlone,
|
||||
# so we always recreate metadata unless the sync is genuinely complete.
|
||||
if [[ "$(drbdadm dstate ha-data 2>/dev/null)" != "UpToDate/UpToDate" ]]; then
|
||||
UUID1=$(_rand_uuid)
|
||||
drbdmeta --force 0 v08 "${DRBD_DISK}" internal create-md
|
||||
drbdmeta --force 0 v08 "${DRBD_DISK}" internal write-dev-uuid "$UUID1"
|
||||
fi
|
||||
|
||||
log "Initialising DRBD metadata on $NODE2..."
|
||||
if [[ "$(n2_ssh "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null)" != "UpToDate/UpToDate" ]]; then
|
||||
UUID2=$(n2_ssh "cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-16 | tr '[:lower:]' '[:upper:]'")
|
||||
n2_ssh "drbdmeta --force 0 v08 ${DRBD_DISK} internal create-md"
|
||||
n2_ssh "drbdmeta --force 0 v08 ${DRBD_DISK} internal write-dev-uuid ${UUID2}"
|
||||
fi
|
||||
|
||||
log "Bringing up DRBD on both nodes..."
|
||||
drbdadm up ha-data 2>/dev/null || true
|
||||
n2_ssh "drbdadm up ha-data" 2>/dev/null || true
|
||||
|
||||
log "Forcing $NODE1 to DRBD Primary for initial sync..."
|
||||
drbdadm primary ha-data --force
|
||||
# NOTE: Pacemaker standby is intentionally kept ON until after the sync
|
||||
# completes. Clearing it here races with the OCF DRBD agent: Pacemaker
|
||||
# sees DRBD in WFConnection/SyncSource and may call drbdadm-down thinking
|
||||
# something went wrong, killing the sync. Standby is cleared below, after
|
||||
# UpToDate/UpToDate is confirmed.
|
||||
|
||||
log "Waiting for DRBD initial sync to complete (32 GB may take 10–20 min)..."
|
||||
log " (monitor with: watch -n3 cat /proc/drbd)"
|
||||
_sync_chars=('|' '/' '-' $'\\')
|
||||
_sync_iter=0
|
||||
while true; do
|
||||
_dstate=$(drbdadm dstate ha-data 2>/dev/null || echo "unknown")
|
||||
if echo "$_dstate" | grep -q "UpToDate/UpToDate"; then
|
||||
printf "\r%-80s\r" ""
|
||||
log "DRBD initial sync complete (dstate: $_dstate)"
|
||||
break
|
||||
fi
|
||||
# Parse connection state from /proc/drbd (cs:SyncSource, cs:Connected, cs:StandAlone …)
|
||||
_cs=$(grep -oE 'cs:[A-Za-z]+' /proc/drbd 2>/dev/null | head -1 | sed 's/cs://' || echo "unknown")
|
||||
# /proc/drbd uses variable whitespace: "sync'ed: 5.2%" (two spaces).
|
||||
_pct=$(grep -oE "sync'ed:[[:space:]]+[0-9.]+" /proc/drbd 2>/dev/null | grep -oE "[0-9.]+" | head -1 || echo "")
|
||||
_eta=$(grep -oE "finish:[[:space:]]+[0-9:]+" /proc/drbd 2>/dev/null | grep -oE "[0-9:]+$" | head -1 || echo "")
|
||||
_spd=$(grep -oE "speed:[[:space:]]+[0-9,]+" /proc/drbd 2>/dev/null | grep -oE "[0-9,]+$" | head -1 || echo "")
|
||||
_sync_iter=$(( _sync_iter + 1 ))
|
||||
_sc="${_sync_chars[$_sync_iter % 4]}"
|
||||
if [[ "$_cs" == "StandAlone" && $_sync_iter -gt 5 ]]; then
|
||||
printf "\r%-80s\r" ""
|
||||
die "DRBD is StandAlone after 15 s — peer connection lost (dstate: $_dstate). " \
|
||||
"Check corosync/network and re-run cluster-init."
|
||||
elif [[ -n "$_pct" ]]; then
|
||||
printf "\r [%s] syncing: %s%% done — ETA %s @ %s K/s " \
|
||||
"$_sc" "$_pct" "${_eta:-??:??:??}" "${_spd:-?}"
|
||||
else
|
||||
printf "\r [%s] cs:%s dstate:%s — waiting for sync to start " "$_sc" "$_cs" "$_dstate"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
log "Disabling Pacemaker maintenance-mode and clearing standby — handing DRBD back to Pacemaker..."
|
||||
crm_attribute -t crm_config -n maintenance-mode -v false 2>/dev/null || true
|
||||
crm_standby -N "$NODE1" -v off 2>/dev/null || true
|
||||
crm_standby -N "$NODE2" -v off 2>/dev/null || true
|
||||
|
||||
# ── 3. XFS filesystem ─────────────────────────────────────────────────────
|
||||
log "Creating XFS on ${DRBD_DEVICE}..."
|
||||
if ! xfs_info "${DRBD_DEVICE}" &>/dev/null; then
|
||||
mkfs.xfs -f "${DRBD_DEVICE}"
|
||||
fi
|
||||
|
||||
log "Mounting ${DRBD_DEVICE} at ${XFS_MOUNT}..."
|
||||
mkdir -p "${XFS_MOUNT}"
|
||||
mountpoint -q "${XFS_MOUNT}" || mount "${DRBD_DEVICE}" "${XFS_MOUNT}"
|
||||
|
||||
# ── 4. NFS dataset directories ────────────────────────────────────────────
|
||||
log "Creating NFS dataset directories..."
|
||||
for subdir in "${NFS_SUBDIRS[@]}"; do
|
||||
mkdir -p "${XFS_MOUNT}/${subdir}"
|
||||
done
|
||||
|
||||
# ── 5. iSCSI LUN backing file ─────────────────────────────────────────────
|
||||
log "Creating iSCSI LUN backing file ${ISCSI_LUN_FILE} (${ISCSI_LUN_SIZE})..."
|
||||
if [[ ! -f "${ISCSI_LUN_FILE}" ]]; then
|
||||
fallocate -l "${ISCSI_LUN_SIZE}" "${ISCSI_LUN_FILE}"
|
||||
fi
|
||||
|
||||
# ── 6. LIO iSCSI target ───────────────────────────────────────────────────
|
||||
log "Configuring LIO iSCSI target via targetcli..."
|
||||
# Note: do NOT bind portal to ${VIP} here — the VIP isn't assigned yet (Pacemaker
|
||||
# creates it). The default portal (all IPs, port 3260) is correct; Pacemaker's
|
||||
# VIP resource will make the target reachable at the VIP address.
|
||||
#
|
||||
# Clear any existing LIO state first (idempotent: re-run after a partial failure).
|
||||
# Use specific delete commands — clearconfig does not reliably clear kernel state.
|
||||
if ls /sys/kernel/config/target/iscsi/ 2>/dev/null | grep -q "${ISCSI_IQN}"; then
|
||||
log "Clearing existing LIO target ${ISCSI_IQN} before reconfiguration..."
|
||||
targetcli "/iscsi delete ${ISCSI_IQN}" 2>/dev/null || true
|
||||
fi
|
||||
if ls /sys/kernel/config/target/core/ 2>/dev/null | grep -q "fileio"; then
|
||||
log "Clearing existing LIO backstore ha-lun0 before reconfiguration..."
|
||||
targetcli "/backstores/fileio delete ha-lun0" 2>/dev/null || true
|
||||
fi
|
||||
targetcli <<EOF
|
||||
/backstores/fileio create name=ha-lun0 file_or_dev=${ISCSI_LUN_FILE} size=0 write_back=false
|
||||
/iscsi create ${ISCSI_IQN}
|
||||
/iscsi/${ISCSI_IQN}/tpg1/luns create /backstores/fileio/ha-lun0
|
||||
/iscsi/${ISCSI_IQN}/tpg1 set attribute authentication=0
|
||||
/iscsi/${ISCSI_IQN}/tpg1 set attribute demo_mode_write_protect=0
|
||||
saveconfig /etc/target/saveconfig.json
|
||||
EOF
|
||||
|
||||
log "Tearing down LIO kernel objects — Pacemaker will restore via targetctl on the Active node..."
|
||||
# LIO holds the backing file open; clear kernel state now so the XFS unmount succeeds.
|
||||
# Use specific delete commands (clearconfig does not reliably clear kernel configfs state).
|
||||
targetcli "/iscsi delete ${ISCSI_IQN}" 2>/dev/null || warn "LIO iscsi delete failed — umount may fail"
|
||||
targetcli "/backstores/fileio delete ha-lun0" 2>/dev/null || warn "LIO backstore delete failed"
|
||||
|
||||
log "Distributing iSCSI saveconfig to $NODE2..."
|
||||
n2_scp /etc/target/saveconfig.json /etc/target/saveconfig.json
|
||||
|
||||
|
||||
log "Unmounting ${XFS_MOUNT} — Pacemaker manages it..."
|
||||
umount "${XFS_MOUNT}" || { sync; umount -l "${XFS_MOUNT}"; }
|
||||
|
||||
log "Demoting DRBD to Secondary — Pacemaker manages primary role..."
|
||||
drbdadm role ha-data 2>/dev/null | grep -q "^Primary" && drbdadm secondary ha-data || true
|
||||
|
||||
# ── 7. Pacemaker resources ────────────────────────────────────────────────
|
||||
log "Configuring Pacemaker cluster properties..."
|
||||
crm_attribute -t crm_config -n stonith-enabled -v false
|
||||
crm_attribute -t crm_config -n no-quorum-policy -v ignore
|
||||
|
||||
log "Creating Pacemaker resources via cibadmin..."
|
||||
# Use cibadmin --replace with pacemaker-4.0-compatible XML.
|
||||
# Key schema rules for pacemaker-4.0:
|
||||
# - globally-unique must be in <meta_attributes>, not a direct <clone> attribute
|
||||
# - promoted-max / promoted-node-max (not master-max / master-node-max)
|
||||
# - constraint with-rsc-role="Promoted" (not "Master")
|
||||
cibadmin --replace --scope resources --xml-text '<resources>
|
||||
<clone id="ms-drbd0">
|
||||
<meta_attributes id="ms-drbd0-meta">
|
||||
<nvpair id="ms-drbd0-globally-unique" name="globally-unique" value="false"/>
|
||||
<nvpair id="ms-drbd0-promotable" name="promotable" value="true"/>
|
||||
<nvpair id="ms-drbd0-promoted-max" name="promoted-max" value="1"/>
|
||||
<nvpair id="ms-drbd0-promoted-node-max" name="promoted-node-max" value="1"/>
|
||||
<nvpair id="ms-drbd0-clone-max" name="clone-max" value="2"/>
|
||||
<nvpair id="ms-drbd0-clone-node-max" name="clone-node-max" value="1"/>
|
||||
<nvpair id="ms-drbd0-notify" name="notify" value="true"/>
|
||||
<nvpair id="ms-drbd0-interleave" name="interleave" value="true"/>
|
||||
</meta_attributes>
|
||||
<primitive id="drbd0" class="ocf" type="drbd" provider="linbit">
|
||||
<instance_attributes id="drbd0-attrs">
|
||||
<nvpair id="drbd0-resource" name="drbd_resource" value="ha-data"/>
|
||||
</instance_attributes>
|
||||
<operations>
|
||||
<op id="drbd0-start" name="start" interval="0" timeout="240s"/>
|
||||
<op id="drbd0-stop" name="stop" interval="0" timeout="120s"/>
|
||||
<op id="drbd0-promote" name="promote" interval="0" timeout="240s"/>
|
||||
<op id="drbd0-demote" name="demote" interval="0" timeout="90s"/>
|
||||
<op id="drbd0-monitor-promoted" name="monitor" interval="20s" timeout="20s" role="Promoted"/>
|
||||
<op id="drbd0-monitor-unpromoted" name="monitor" interval="30s" timeout="20s" role="Unpromoted"/>
|
||||
</operations>
|
||||
</primitive>
|
||||
</clone>
|
||||
<group id="ha-group">
|
||||
<primitive id="xfs-data" class="ocf" type="Filesystem" provider="heartbeat">
|
||||
<instance_attributes id="xfs-data-attrs">
|
||||
<nvpair id="xfs-data-device" name="device" value="/dev/drbd0"/>
|
||||
<nvpair id="xfs-data-directory" name="directory" value="/srv/ha-data"/>
|
||||
<nvpair id="xfs-data-fstype" name="fstype" value="xfs"/>
|
||||
<nvpair id="xfs-data-options" name="options" value="defaults"/>
|
||||
<nvpair id="xfs-data-force_unmount" name="force_unmount" value="true"/>
|
||||
</instance_attributes>
|
||||
<operations>
|
||||
<op id="xfs-data-start" name="start" interval="0" timeout="60s"/>
|
||||
<op id="xfs-data-stop" name="stop" interval="0" timeout="60s"/>
|
||||
<op id="xfs-data-monitor" name="monitor" interval="20s" timeout="40s"/>
|
||||
</operations>
|
||||
</primitive>
|
||||
<primitive id="iscsi-target" class="systemd" type="targetctl">
|
||||
<operations>
|
||||
<op id="iscsi-start" name="start" interval="0" timeout="60s"/>
|
||||
<op id="iscsi-stop" name="stop" interval="0" timeout="60s"/>
|
||||
<op id="iscsi-monitor" name="monitor" interval="20s" timeout="40s"/>
|
||||
</operations>
|
||||
</primitive>
|
||||
<primitive id="nfs-server" class="systemd" type="nfs-server">
|
||||
<operations>
|
||||
<op id="nfs-start" name="start" interval="0" timeout="60s"/>
|
||||
<op id="nfs-stop" name="stop" interval="0" timeout="60s"/>
|
||||
<op id="nfs-monitor" name="monitor" interval="30s" timeout="40s"/>
|
||||
</operations>
|
||||
</primitive>
|
||||
<primitive id="vip-storage" class="ocf" type="IPaddr2" provider="heartbeat">
|
||||
<instance_attributes id="vip-storage-attrs">
|
||||
<nvpair id="vip-storage-ip" name="ip" value="192.168.20.229"/>
|
||||
<nvpair id="vip-storage-cidr" name="cidr_netmask" value="24"/>
|
||||
<nvpair id="vip-storage-nic" name="nic" value="ens20"/>
|
||||
</instance_attributes>
|
||||
<operations>
|
||||
<op id="vip-storage-start" name="start" interval="0" timeout="20s"/>
|
||||
<op id="vip-storage-stop" name="stop" interval="0" 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>
|
||||
</primitive>
|
||||
</group>
|
||||
</resources>'
|
||||
|
||||
log "Adding Pacemaker ordering and colocation constraints..."
|
||||
cibadmin --replace --scope constraints --xml-text '<constraints>
|
||||
<rsc_order id="order-drbd-group" first="ms-drbd0" first-action="promote" then="ha-group" then-action="start" kind="Mandatory"/>
|
||||
<rsc_colocation id="coloc-group-with-drbd" score="INFINITY" rsc="ha-group" with-rsc="ms-drbd0" with-rsc-role="Promoted"/>
|
||||
</constraints>'
|
||||
|
||||
log "Clearing stale Pacemaker failure history..."
|
||||
crm_resource --cleanup 2>/dev/null || true
|
||||
|
||||
log "Waiting for resources to start..."
|
||||
for i in $(seq 1 60); do
|
||||
if crm_resource -r vip-storage --locate 2>/dev/null | grep -q "running on"; then
|
||||
log "VIPs are up: $(crm_resource -r vip-storage --locate)"
|
||||
break
|
||||
fi
|
||||
[[ $i -eq 60 ]] && { warn "VIPs not up after 120 s — check: crm_mon -1"; break; }
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log ""
|
||||
log "═══════════════════════════════════════════════════════════════"
|
||||
log " HA cluster initialised."
|
||||
log ""
|
||||
log " crm_mon -1 — cluster status"
|
||||
log " iscsiadm -m discovery -t st -p ${VIP} — verify iSCSI (storage net)"
|
||||
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 " To enable STONITH (after deploying fence SSH key):"
|
||||
log " 1. Fill in VMID_NODE1 / VMID_NODE2 in cluster-enable-stonith.sh"
|
||||
log " 2. Copy scripts/ha/fence-pve-ssh.py to /etc/pacemaker/fence_pve_ssh"
|
||||
log " on both nodes (chmod +x)"
|
||||
log " 3. Generate and distribute the fence SSH key"
|
||||
log " (see docs or cluster-enable-stonith.sh header)"
|
||||
log " 4. bash scripts/ha/cluster-enable-stonith.sh"
|
||||
log "═══════════════════════════════════════════════════════════════"
|
||||
Executable
+499
@@ -0,0 +1,499 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — Full lifecycle management for the HA file-server cluster.
|
||||
#
|
||||
# Handles everything from zero (no VMs, no secrets) through a running,
|
||||
# tested cluster, and optionally tears it back down.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ha/deploy.sh [options]
|
||||
# scripts/ha/deploy.sh --destroy [options]
|
||||
#
|
||||
# Phases (all run by default; skip any with --skip-*):
|
||||
# 1. ensure-bridge Create storage bridge (vmbr1) on the Proxmox node if absent.
|
||||
# 2. sync-keys Generate SSH host keys and register age keys for both nodes.
|
||||
# 3. create-vms Build disk images and create both VMs via create-proxmox-resource.sh.
|
||||
# 4. add-hardware Attach storage NIC (vmbr1) and DRBD data disk to each VM.
|
||||
# 5. boot-wait Start VMs, wait for SSH on both nodes.
|
||||
# 6. cluster-init Form the cluster: DRBD, corosync, Pacemaker, NFS, VIP.
|
||||
# Also encrypts the generated corosync authkey into the repo.
|
||||
# 7. run-tests Run acceptance tests (T1–T7).
|
||||
#
|
||||
# Options:
|
||||
# --node <host> Proxmox host to deploy on (default: pve1.sweet.home)
|
||||
# --vmid1 <n> VMID for ha-server-1 (default: 200)
|
||||
# --vmid2 <n> VMID for ha-server-2 (default: 201)
|
||||
# --storage <pool> Proxmox storage pool (default: local-zfs)
|
||||
# --storage-bridge <br> Bridge for HA storage network (default: vmbr1)
|
||||
# --drbd-disk-gb <n> DRBD data disk size in GB (default: 32)
|
||||
# --memory <MB> RAM per node (default: 4096)
|
||||
# --cores <n> vCPUs per node (default: 4)
|
||||
# --skip-ensure-bridge Skip storage bridge creation/check
|
||||
# --skip-sync-keys Skip sync-host-keys.sh (clan vars already exist)
|
||||
# --skip-create-vms Skip VM creation (VMs already exist)
|
||||
# --skip-add-hardware Skip net1/scsi1 attachment (already attached)
|
||||
# --skip-boot-wait Skip boot/SSH wait (VMs already running)
|
||||
# --skip-refresh-sops-keys Skip scanning running VMs for fresh SSH host keys
|
||||
# --skip-cluster-init Skip cluster formation (cluster already configured)
|
||||
# --skip-tests Skip acceptance tests
|
||||
# --force-rebuild Pass --force-rebuild to create-proxmox-resource.sh
|
||||
# --destroy Stop and delete both VMs (skip all other phases)
|
||||
# --dry-run Print what would run without executing
|
||||
# -h|--help Show this message
|
||||
#
|
||||
# Prerequisites:
|
||||
# - SSH access to the Proxmox node as $PROXMOX_SSH_USER (wayne).
|
||||
# - sops age key in the standard location (used by sync-host-keys.sh).
|
||||
# - For --skip-sync-keys: clan vars already in vars/per-machine/proxmox-ha-server-{1,2}/.
|
||||
# - For full tests: secrets/common.yaml decryptable on both nodes (run
|
||||
# `sops updatekeys secrets/common.yaml` after sync-keys).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# shellcheck source=../env.sh
|
||||
source "${REPO_ROOT}/scripts/env.sh"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE="${PROXMOX_HOST:-$PVE1_HOST}"
|
||||
VMID1=200
|
||||
VMID2=201
|
||||
STORAGE="${PROXMOX_STORAGE:-local-zfs}"
|
||||
STORAGE_BRIDGE="vmbr1"
|
||||
DRBD_DISK_GB=32
|
||||
MEMORY_MB=4096
|
||||
CORES=4
|
||||
|
||||
SKIP_ENSURE_BRIDGE=false
|
||||
SKIP_SYNC_KEYS=false
|
||||
SKIP_CREATE_VMS=false
|
||||
SKIP_ADD_HARDWARE=false
|
||||
SKIP_BOOT_WAIT=false
|
||||
SKIP_REFRESH_SOPS_KEYS=false
|
||||
SKIP_CLUSTER_INIT=false
|
||||
SKIP_TESTS=false
|
||||
FORCE_REBUILD=false
|
||||
DESTROY=false
|
||||
DRY_RUN=false
|
||||
|
||||
# ── Variables from repo ───────────────────────────────────────────────────────
|
||||
|
||||
NODE1_HOST="ha-server-1"
|
||||
NODE2_HOST="ha-server-2"
|
||||
NODE1_IP="192.168.2.228"
|
||||
NODE2_IP="192.168.2.227"
|
||||
STORAGE_IP1="192.168.10.228"
|
||||
STORAGE_IP2="192.168.10.227"
|
||||
STORAGE_CIDR="192.168.10.224/29"
|
||||
SSH_USER="${PROXMOX_SSH_USER:-wayne}"
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \?//; p } }' "$0"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--node) NODE="$2"; shift 2 ;;
|
||||
--vmid1) VMID1="$2"; shift 2 ;;
|
||||
--vmid2) VMID2="$2"; shift 2 ;;
|
||||
--storage) STORAGE="$2"; shift 2 ;;
|
||||
--storage-bridge) STORAGE_BRIDGE="$2"; shift 2 ;;
|
||||
--drbd-disk-gb) DRBD_DISK_GB="$2"; shift 2 ;;
|
||||
--memory) MEMORY_MB="$2"; shift 2 ;;
|
||||
--cores) CORES="$2"; shift 2 ;;
|
||||
--skip-ensure-bridge) SKIP_ENSURE_BRIDGE=true; shift ;;
|
||||
--skip-sync-keys) SKIP_SYNC_KEYS=true; shift ;;
|
||||
--skip-create-vms) SKIP_CREATE_VMS=true; shift ;;
|
||||
--skip-add-hardware) SKIP_ADD_HARDWARE=true; shift ;;
|
||||
--skip-boot-wait) SKIP_BOOT_WAIT=true; shift ;;
|
||||
--skip-refresh-sops-keys) SKIP_REFRESH_SOPS_KEYS=true; shift ;;
|
||||
--skip-cluster-init) SKIP_CLUSTER_INIT=true; shift ;;
|
||||
--skip-tests) SKIP_TESTS=true; shift ;;
|
||||
--force-rebuild) FORCE_REBUILD=true; shift ;;
|
||||
--destroy) DESTROY=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
logn() { echo " $*"; }
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
run() {
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
pve() {
|
||||
# Run a command on the Proxmox node via SSH.
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] ssh ${SSH_USER}@${NODE} sudo $*"
|
||||
else
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
|
||||
fi
|
||||
}
|
||||
|
||||
pve_check() {
|
||||
# Run a read-only probe on the Proxmox node — always executes even in dry-run.
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
|
||||
}
|
||||
|
||||
HA_USER="nixos"
|
||||
|
||||
n1() {
|
||||
# Run a command on ha-server-1 via SSH as nixos with sudo.
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
n2() {
|
||||
# Run a command on ha-server-2 via SSH as nixos with sudo.
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
local ip="$1" label="$2"
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Skipping SSH wait for ${label} (${ip})"
|
||||
return 0
|
||||
fi
|
||||
local deadline=$(( $(date +%s) + 300 ))
|
||||
log "Waiting for SSH on ${label} (${ip}) — up to 5 min..."
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=3 \
|
||||
-o BatchMode=yes "${HA_USER}@${ip}" true 2>/dev/null; then
|
||||
logn "${label} is up."
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
err "Timed out waiting for SSH on ${label} (${ip})"
|
||||
}
|
||||
|
||||
# ── Destroy mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
if $DESTROY; then
|
||||
log "Destroying HA cluster VMs (${VMID1}=${NODE1_HOST}, ${VMID2}=${NODE2_HOST}) on ${NODE}"
|
||||
for vmid in "$VMID1" "$VMID2"; do
|
||||
STATUS=$(pve "qm status ${vmid} 2>/dev/null" 2>/dev/null || true)
|
||||
if echo "$STATUS" | grep -q "running"; then
|
||||
log "Stopping VMID ${vmid}..."
|
||||
pve "qm stop ${vmid} --skiplock 1"
|
||||
sleep 5
|
||||
fi
|
||||
if $DRY_RUN || pve "qm config ${vmid} >/dev/null 2>&1"; then
|
||||
log "Deleting VMID ${vmid}..."
|
||||
run pve "qm destroy ${vmid} --purge 1"
|
||||
else
|
||||
logn "VMID ${vmid} not found — already gone."
|
||||
fi
|
||||
done
|
||||
log "Done — cluster VMs destroyed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Phase 1: Storage bridge ───────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_ENSURE_BRIDGE; then
|
||||
log "Phase 1: Ensuring storage bridge ${STORAGE_BRIDGE} on ${NODE}"
|
||||
if pve_check "test -d /sys/class/net/${STORAGE_BRIDGE}" &>/dev/null; then
|
||||
logn "${STORAGE_BRIDGE} already exists — skipping."
|
||||
else
|
||||
logn "Creating isolated internal bridge ${STORAGE_BRIDGE} (no upstream port, ${STORAGE_CIDR})"
|
||||
BRIDGE_CONF="auto ${STORAGE_BRIDGE}
|
||||
iface ${STORAGE_BRIDGE} inet manual
|
||||
bridge-ports none
|
||||
bridge-stp off
|
||||
bridge-fd 0"
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] Would write /etc/network/interfaces.d/${STORAGE_BRIDGE}.conf and ifup it"
|
||||
else
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
|
||||
"echo '${BRIDGE_CONF}' | sudo tee /etc/network/interfaces.d/${STORAGE_BRIDGE}.conf > /dev/null && sudo ifup ${STORAGE_BRIDGE}"
|
||||
logn "${STORAGE_BRIDGE} created and brought up."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 2: Sync host keys ───────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_SYNC_KEYS; then
|
||||
log "Phase 2: Syncing SSH host keys for both HA targets"
|
||||
for target in proxmox-ha-server-1 proxmox-ha-server-2; do
|
||||
CLAN_DIR="${REPO_ROOT}/vars/per-machine/${target}/openssh"
|
||||
if [[ -d "$CLAN_DIR" ]]; then
|
||||
logn "Clan vars for ${target} already exist — skipping."
|
||||
else
|
||||
logn "Generating host keys for ${target}..."
|
||||
run bash "${REPO_ROOT}/scripts/secrets/sync-host-keys.sh" "$target"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Phase 2.5: Prepare Proxmox node for building ─────────────────────────────
|
||||
if ! $SKIP_CREATE_VMS && ! $DRY_RUN; then
|
||||
CURRENT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
# Fix /nix ownership if it exists but belongs to a different UID.
|
||||
# pve1's IPA-enrolled wayne (UID 50002) can't write to a store created by
|
||||
# another UID — passwordless sudo corrects it once.
|
||||
# Use direct SSH (no sudo) for the writability check so we test wayne's own
|
||||
# access, not root's.
|
||||
local_ssh() { ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "$*"; }
|
||||
if local_ssh "test -d /nix" &>/dev/null && ! local_ssh "test -w /nix" &>/dev/null; then
|
||||
logn "/nix exists but not writable by ${SSH_USER} — fixing ownership with sudo (one-time)..."
|
||||
local_ssh "sudo chown -R ${SSH_USER} /nix"
|
||||
logn "Done."
|
||||
fi
|
||||
unset -f local_ssh
|
||||
|
||||
# Ensure the remote clone is on the correct branch so create-proxmox-resource.sh
|
||||
# builds from the same commits we're deploying.
|
||||
REMOTE_REPO="/home/${SSH_USER}/nixos"
|
||||
if pve_check "test -d ${REMOTE_REPO}/.git" &>/dev/null; then
|
||||
REMOTE_BRANCH=$(ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
|
||||
"cd ${REMOTE_REPO} && git rev-parse --abbrev-ref HEAD 2>/dev/null")
|
||||
if [[ "$REMOTE_BRANCH" != "$CURRENT_BRANCH" ]]; then
|
||||
logn "Remote clone is on '${REMOTE_BRANCH}', switching to '${CURRENT_BRANCH}'..."
|
||||
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
|
||||
"cd ${REMOTE_REPO} && git fetch origin && git checkout '${CURRENT_BRANCH}' && git pull --ff-only"
|
||||
logn "Done."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 3: Create VMs ───────────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_CREATE_VMS; then
|
||||
log "Phase 3: Building and creating VMs on ${NODE}"
|
||||
|
||||
CREATE="${REPO_ROOT}/scripts/proxmox/create-proxmox-resource.sh"
|
||||
|
||||
for spec in "${VMID1}:ha-server-1:proxmox-ha-server-1" "${VMID2}:ha-server-2:proxmox-ha-server-2"; do
|
||||
IFS=: read -r vmid host_name flake_target <<< "$spec"
|
||||
log "Creating ${flake_target} (VMID ${vmid}) on ${NODE}..."
|
||||
# Always --force-rebuild: create-proxmox-resource.sh only calls
|
||||
# sync_remote_host_keys (which populates host-keys/ for proxmox.nix to
|
||||
# bake the clan-var SSH key into the disko image) when it actually builds.
|
||||
# Reusing a cached image skips that step, so destroy+recreate would reuse
|
||||
# an image with a stale/random key baked in → sops fails on first boot.
|
||||
run bash "$CREATE" \
|
||||
--type vm \
|
||||
--host "$host_name" \
|
||||
--vmid "$vmid" \
|
||||
--node "$NODE" \
|
||||
--storage "$STORAGE" \
|
||||
--memory "$MEMORY_MB" \
|
||||
--cores "$CORES" \
|
||||
--force-rebuild
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Phase 4: Add storage NIC and DRBD disk ────────────────────────────────────
|
||||
|
||||
if ! $SKIP_ADD_HARDWARE; then
|
||||
log "Phase 4: Attaching storage NIC (${STORAGE_BRIDGE}) and DRBD disk (${DRBD_DISK_GB}G) to each VM"
|
||||
for vmid in "$VMID1" "$VMID2"; do
|
||||
log " VMID ${vmid}: stopping to add hardware..."
|
||||
pve "qm stop ${vmid} --skiplock 1 2>/dev/null; sleep 3" || true
|
||||
|
||||
logn "Adding net1 (${STORAGE_BRIDGE})..."
|
||||
pve "qm set ${vmid} --net1 virtio,bridge=${STORAGE_BRIDGE},firewall=0"
|
||||
|
||||
logn "Adding scsi1 (${STORAGE}:${DRBD_DISK_GB}G for DRBD)..."
|
||||
pve "qm set ${vmid} --scsi1 ${STORAGE}:${DRBD_DISK_GB},format=raw"
|
||||
|
||||
logn "Starting VMID ${vmid}..."
|
||||
pve "qm start ${vmid}"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Phase 5: Wait for SSH ─────────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_BOOT_WAIT; then
|
||||
log "Phase 5: Waiting for both nodes to come up"
|
||||
wait_for_ssh "$NODE1_IP" "$NODE1_HOST"
|
||||
wait_for_ssh "$NODE2_IP" "$NODE2_HOST"
|
||||
logn "Both nodes are SSHable."
|
||||
# Give systemd a few seconds to settle after activation
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
# ── Phase 5.5: Refresh sops host-key registrations ───────────────────────────
|
||||
#
|
||||
# Disko builds raw disk images; each new VM boots with a freshly-generated SSH
|
||||
# host key rather than the one pre-seeded in clan vars. This phase scans the
|
||||
# actual running VMs, and if their ed25519 host keys differ from what clan vars
|
||||
# record: updates the clan var pub-key files, rewrites the .sops.yaml age-key
|
||||
# anchors, and re-encrypts all affected sops files so the nodes can decrypt
|
||||
# secrets on the next nixos-rebuild. Safe no-op when keys haven't changed.
|
||||
|
||||
if ! $SKIP_REFRESH_SOPS_KEYS; then
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would scan VM host keys and refresh .sops.yaml / secrets if needed"
|
||||
else
|
||||
log "Phase 5.5: Refreshing sops host-key registrations (disko key drift fix)"
|
||||
SOPS_UPDATED=false
|
||||
|
||||
for spec in \
|
||||
"${NODE1_IP}:proxmox-ha-server-1:${NODE1_HOST}" \
|
||||
"${NODE2_IP}:proxmox-ha-server-2:${NODE2_HOST}"; do
|
||||
IFS=: read -r node_ip flake_target host_name <<< "$spec"
|
||||
CLAN_PUB="${REPO_ROOT}/vars/per-machine/${flake_target}/openssh/ssh_host_ed25519_key.pub/value"
|
||||
|
||||
logn "Scanning ed25519 host key from ${host_name} (${node_ip})..."
|
||||
RAW=$(ssh-keyscan -t ed25519 "${node_ip}" 2>/dev/null | grep -v "^#") || true
|
||||
if [[ -z "$RAW" ]]; then
|
||||
logn "WARNING: no ed25519 key returned by ssh-keyscan for ${node_ip} — skipping"
|
||||
continue
|
||||
fi
|
||||
# ssh-keyscan returns: <ip> ssh-ed25519 <b64key>
|
||||
SCANNED_TYPE=$(awk '{print $2}' <<< "$RAW")
|
||||
SCANNED_KEY=$(awk '{print $3}' <<< "$RAW")
|
||||
SCANNED_PUBKEY="${SCANNED_TYPE} ${SCANNED_KEY} ${host_name}"
|
||||
|
||||
CURRENT=$(tr -d '\n' < "$CLAN_PUB" 2>/dev/null || true)
|
||||
if [[ "$SCANNED_PUBKEY" == "$CURRENT" ]]; then
|
||||
logn "${host_name}: clan var matches running key — no update needed"
|
||||
continue
|
||||
fi
|
||||
|
||||
logn "${host_name}: key drift detected — updating clan var"
|
||||
logn " old: ${CURRENT}"
|
||||
logn " new: ${SCANNED_PUBKEY}"
|
||||
echo "$SCANNED_PUBKEY" > "$CLAN_PUB"
|
||||
SOPS_UPDATED=true
|
||||
|
||||
# Rewrite the .sops.yaml anchor for this host with the new age key.
|
||||
ANCHOR="${flake_target}" # e.g. proxmox-ha-server-1
|
||||
NEW_AGE=$(echo "$SCANNED_PUBKEY" | \
|
||||
nix run --quiet --no-warn-dirty nixpkgs#ssh-to-age 2>/dev/null)
|
||||
if [[ -z "$NEW_AGE" ]]; then
|
||||
err "ssh-to-age produced no output for ${host_name} — check nixpkgs#ssh-to-age"
|
||||
fi
|
||||
logn " new age key: ${NEW_AGE}"
|
||||
sed -i "/&${ANCHOR} /s| age[a-z0-9]*$| ${NEW_AGE}|" "${REPO_ROOT}/.sops.yaml"
|
||||
done
|
||||
|
||||
if $SOPS_UPDATED; then
|
||||
logn "Running sops updatekeys on affected secrets..."
|
||||
SOPS="nix run --quiet --no-warn-dirty nixpkgs#sops --"
|
||||
(cd "${REPO_ROOT}" && \
|
||||
$SOPS updatekeys -y secrets/common.yaml && \
|
||||
$SOPS updatekeys -y secrets/ha-server-1.yaml && \
|
||||
$SOPS updatekeys -y secrets/ha-server-2.yaml && \
|
||||
$SOPS updatekeys -y secrets/ha-server-1.keytab && \
|
||||
$SOPS updatekeys -y secrets/ha-server-2.keytab)
|
||||
# Note: ha-corosync-authkey is re-generated and re-encrypted by cluster-init below.
|
||||
|
||||
logn "Committing refreshed host keys and re-encrypted secrets..."
|
||||
(cd "${REPO_ROOT}" && \
|
||||
git add \
|
||||
vars/per-machine/proxmox-ha-server-1/openssh/ssh_host_ed25519_key.pub/value \
|
||||
vars/per-machine/proxmox-ha-server-2/openssh/ssh_host_ed25519_key.pub/value \
|
||||
.sops.yaml \
|
||||
secrets/common.yaml \
|
||||
secrets/ha-server-1.yaml \
|
||||
secrets/ha-server-2.yaml \
|
||||
secrets/ha-server-1.keytab \
|
||||
secrets/ha-server-2.keytab && \
|
||||
git commit -m "secrets(ha): refresh sops host-key registrations for new VM instances" || true)
|
||||
logn "Sops keys refreshed and committed."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 6: Cluster init ─────────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_CLUSTER_INIT; then
|
||||
log "Phase 6: Initialising HA cluster"
|
||||
|
||||
CLUSTER_INIT="${REPO_ROOT}/scripts/ha/cluster-init.sh"
|
||||
[[ -x "$CLUSTER_INIT" ]] || chmod +x "$CLUSTER_INIT"
|
||||
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would generate temp key, authorise on ${NODE2_HOST}, scp cluster-init.sh to ${NODE1_HOST}, and run it as root via sudo"
|
||||
else
|
||||
# Generate a temp keypair so cluster-init.sh can SSH node1→node2 as ${HA_USER}.
|
||||
# Root on node1 has no keys; a temp key bridging node1→node2 nixos solves this.
|
||||
TEMP_KEY="${REPO_ROOT}/.tmp-cluster-init-key"
|
||||
TEMP_KEY_PUB="${TEMP_KEY}.pub"
|
||||
rm -f "$TEMP_KEY" "$TEMP_KEY_PUB"
|
||||
ssh-keygen -t ed25519 -f "$TEMP_KEY" -N "" -C "cluster-init-temp-$(date +%s)" -q
|
||||
TEMP_PUBKEY=$(cat "$TEMP_KEY_PUB")
|
||||
|
||||
logn "Authorising temp key on ${NODE2_HOST} for ${HA_USER}..."
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE2_IP}" \
|
||||
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '${TEMP_PUBKEY}' >> ~/.ssh/authorized_keys"
|
||||
|
||||
logn "Placing temp key on ${NODE1_HOST} for root..."
|
||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
|
||||
"$TEMP_KEY" "${HA_USER}@${NODE1_IP}:/tmp/cluster-init-key"
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
|
||||
"sudo mkdir -p /root/.ssh && sudo cp /tmp/cluster-init-key /root/.ssh/cluster-init-key && \
|
||||
sudo chmod 600 /root/.ssh/cluster-init-key && rm -f /tmp/cluster-init-key"
|
||||
|
||||
logn "Uploading cluster-init.sh to ${NODE1_HOST}..."
|
||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
|
||||
"$CLUSTER_INIT" "${HA_USER}@${NODE1_IP}:/tmp/cluster-init.sh"
|
||||
|
||||
logn "Running cluster-init.sh on ${NODE1_HOST}..."
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
|
||||
"sudo env NODE1=${NODE1_HOST} NODE2=${NODE2_HOST} \
|
||||
NODE1_IP=${NODE1_IP} NODE2_IP=${NODE2_IP} \
|
||||
VIP=192.168.20.229 XFS_MOUNT=/srv/ha-data \
|
||||
ISCSI_IQN=iqn.2026-01.home.sweet:ha-storage \
|
||||
VMID_NODE1=${VMID1} VMID_NODE2=${VMID2} \
|
||||
HA_USER=${HA_USER} HA_KEY=/root/.ssh/cluster-init-key \
|
||||
bash /tmp/cluster-init.sh"
|
||||
|
||||
logn "Cleaning up temp key from both nodes..."
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE2_IP}" \
|
||||
"sed -i '/cluster-init-temp/d' ~/.ssh/authorized_keys" 2>/dev/null || true
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
|
||||
"sudo rm -f /root/.ssh/cluster-init-key" 2>/dev/null || true
|
||||
rm -f "$TEMP_KEY" "$TEMP_KEY_PUB"
|
||||
|
||||
# Encrypt the corosync authkey generated by cluster-init and commit it.
|
||||
log " Encrypting corosync authkey into secrets/ha-corosync-authkey..."
|
||||
AUTHKEY_TMP="${REPO_ROOT}/secrets/ha-corosync-authkey.tmp"
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no "${HA_USER}@${NODE1_IP}" \
|
||||
"sudo cat /etc/corosync/authkey" > "$AUTHKEY_TMP"
|
||||
if [[ ! -s "$AUTHKEY_TMP" ]]; then
|
||||
err "corosync authkey on node1 is empty — cluster-init may have failed."
|
||||
fi
|
||||
mv "$AUTHKEY_TMP" "${REPO_ROOT}/secrets/ha-corosync-authkey"
|
||||
(cd "${REPO_ROOT}" && nix run nixpkgs#sops -- -e --input-type binary -i secrets/ha-corosync-authkey)
|
||||
logn "Authkey encrypted. Committing..."
|
||||
(cd "${REPO_ROOT}" && git add secrets/ha-corosync-authkey && \
|
||||
git commit -m "secrets(ha): encrypt corosync authkey generated by cluster-init")
|
||||
logn "Committed."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 7: Acceptance tests ─────────────────────────────────────────────────
|
||||
|
||||
if ! $SKIP_TESTS; then
|
||||
log "Phase 7: Running acceptance tests (T1–T7)"
|
||||
if $DRY_RUN; then
|
||||
logn "[dry-run] Would run acceptance-tests.sh against ${NODE1_HOST}/${NODE2_HOST}"
|
||||
else
|
||||
NODE1="$NODE1_HOST" NODE2="$NODE2_HOST" \
|
||||
NODE1_IP="$NODE1_IP" NODE2_IP="$NODE2_IP" \
|
||||
VIP="192.168.20.229" \
|
||||
bash "${REPO_ROOT}/scripts/ha/acceptance-tests.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Deploy complete."
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
# failover.sh — graceful HA cluster failover
|
||||
#
|
||||
# Detects which node is active and moves all resources to the other node by
|
||||
# putting the active node into Pacemaker standby. Waits for the XFS mount to
|
||||
# appear on the target before returning.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ha/failover.sh [--to node1|node2] [--force] [--timeout <s>] [--dry-run]
|
||||
#
|
||||
# --to node1|node2 target node (default: the node that is NOT currently active)
|
||||
# --force skip the interactive confirmation prompt
|
||||
# --timeout <s> seconds to wait for resources to move (default: 120)
|
||||
# --dry-run show what would be done without changing anything
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}"
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}"
|
||||
VIP="${VIP:-192.168.20.229}"
|
||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}"
|
||||
HA_USER="${HA_USER:-nixos}"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────
|
||||
TARGET_NODE=""
|
||||
FORCE=false
|
||||
DRY_RUN=false
|
||||
TIMEOUT=120
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--to)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
node1|ha-server-1) TARGET_NODE="$NODE1" ;;
|
||||
node2|ha-server-2) TARGET_NODE="$NODE2" ;;
|
||||
*) echo "ERROR: --to must be node1, node2, ha-server-1, or ha-server-2"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
--force) FORCE=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--timeout) shift; TIMEOUT="${1:?--timeout requires a value}" ;;
|
||||
*) echo "Unknown argument: $1"; echo "Usage: $0 [--to node1|node2] [--force] [--timeout <s>] [--dry-run]"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
DRY_PREFIX=""
|
||||
$DRY_RUN && DRY_PREFIX="[dry-run] "
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Cluster Failover — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
$DRY_RUN && echo " MODE: dry-run — no changes will be made"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Detect active node ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Detecting active node..."
|
||||
|
||||
CRM_OUT=""
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$CRM_OUT" ]]; then
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n2 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# crm_mon 2.x formats Promoted lines as " * Promoted: [ node ]" — the * bullet
|
||||
# means ^\s*(Promoted|Masters): never matches; filter Unpromoted first instead.
|
||||
ACTIVE_NODE=$(echo "$CRM_OUT" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo ""
|
||||
echo "ERROR: could not determine active node from crm_mon."
|
||||
echo " Is Pacemaker still settling? Try running scripts/ha/health.sh first."
|
||||
echo " If Pacemaker is down on both nodes, manual recovery is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
ACTIVE_IP="$NODE1_IP"
|
||||
STANDBY_NODE="$NODE2"
|
||||
STANDBY_IP="$NODE2_IP"
|
||||
na() { n1 "$@"; }
|
||||
ns() { n2 "$@"; }
|
||||
else
|
||||
ACTIVE_IP="$NODE2_IP"
|
||||
STANDBY_NODE="$NODE1"
|
||||
STANDBY_IP="$NODE1_IP"
|
||||
na() { n2 "$@"; }
|
||||
ns() { n1 "$@"; }
|
||||
fi
|
||||
|
||||
echo " Active: $ACTIVE_NODE ($ACTIVE_IP)"
|
||||
echo " Standby: $STANDBY_NODE ($STANDBY_IP)"
|
||||
|
||||
# ── Validate target ────────────────────────────────────────────────────────
|
||||
if [[ -n "$TARGET_NODE" ]]; then
|
||||
if [[ "$TARGET_NODE" == "$ACTIVE_NODE" ]]; then
|
||||
echo ""
|
||||
echo "ERROR: $TARGET_NODE is already the active node — nothing to do."
|
||||
exit 1
|
||||
fi
|
||||
echo " Target: $TARGET_NODE (as requested)"
|
||||
else
|
||||
echo " Target: $STANDBY_NODE (auto — the other node)"
|
||||
fi
|
||||
|
||||
# ── Pre-checks ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Pre-checks..."
|
||||
|
||||
DRBD_DSTATE=$(na "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if ! echo "$DRBD_DSTATE" | grep -q "UpToDate/UpToDate"; then
|
||||
echo ""
|
||||
echo " WARNING: DRBD dstate is '$DRBD_DSTATE' (not UpToDate/UpToDate)."
|
||||
echo " Failing over with a partially-synced disk risks split-brain."
|
||||
if ! $FORCE; then
|
||||
echo " Use --force to proceed anyway (not recommended)."
|
||||
exit 1
|
||||
fi
|
||||
echo " --force specified — proceeding despite non-ideal DRBD state."
|
||||
else
|
||||
echo " DRBD dstate: $DRBD_DSTATE — OK"
|
||||
fi
|
||||
|
||||
QUORUM_OK=$(na "corosync-quorumtool -s 2>/dev/null | grep -c 'Quorate:.*Yes'" 2>/dev/null || echo "0")
|
||||
if [[ "$QUORUM_OK" -lt 1 ]]; then
|
||||
echo " ERROR: cluster does not have quorum — failover would be unsafe."
|
||||
exit 1
|
||||
fi
|
||||
echo " Quorum: OK"
|
||||
|
||||
# ── Confirm ────────────────────────────────────────────────────────────────
|
||||
if ! $FORCE && ! $DRY_RUN; then
|
||||
echo ""
|
||||
echo " This will move all resources from $ACTIVE_NODE → $STANDBY_NODE."
|
||||
echo " VIP and services will be unreachable for ~10–30 seconds."
|
||||
printf " Proceed? [y/N] "
|
||||
read -r ANSWER
|
||||
[[ "${ANSWER,,}" == "y" || "${ANSWER,,}" == "yes" ]] || { echo "Aborted."; exit 0; }
|
||||
fi
|
||||
|
||||
# ── Capture active node's crm_node name ───────────────────────────────────
|
||||
# crm_node -n returns the node name as registered in Pacemaker (may differ
|
||||
# from hostname if Pacemaker was configured with explicit node names).
|
||||
ACTIVE_CRMD_NAME=$(na "crm_node -n 2>/dev/null" 2>/dev/null || echo "$ACTIVE_NODE")
|
||||
|
||||
# ── Perform failover ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "${DRY_PREFIX}Putting $ACTIVE_NODE into standby (resources will migrate to $STANDBY_NODE)..."
|
||||
if ! $DRY_RUN; then
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v on" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Wait for resources to move ─────────────────────────────────────────────
|
||||
echo "${DRY_PREFIX}Waiting up to ${TIMEOUT}s for XFS to mount on $STANDBY_NODE..."
|
||||
MOVED=false
|
||||
SPIN_CHARS=('|' '/' '-' '\')
|
||||
SPIN_I=0
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo " [dry-run] would wait for mountpoint $XFS_MOUNT on $STANDBY_NODE"
|
||||
MOVED=true
|
||||
else
|
||||
for i in $(seq 1 "$TIMEOUT"); do
|
||||
if ns "mountpoint -q '${XFS_MOUNT}' 2>/dev/null" 2>/dev/null; then
|
||||
printf "\r%-80s\r" ""
|
||||
echo " Resources moved in ${i}s"
|
||||
MOVED=true
|
||||
break
|
||||
fi
|
||||
SPIN_I=$(( SPIN_I + 1 ))
|
||||
SC="${SPIN_CHARS[$((SPIN_I % 4))]}"
|
||||
printf "\r [%s] waiting... (%ds) " "$SC" "$i"
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if ! $MOVED; then
|
||||
echo ""
|
||||
echo "ERROR: XFS did not mount on $STANDBY_NODE within ${TIMEOUT}s."
|
||||
echo ""
|
||||
echo " Current resource state:"
|
||||
na "crm_mon -1 2>/dev/null" 2>/dev/null | grep -E 'Started|Stopped|Promoted|Unpromoted|FAILED' | sed 's/^/ /' || true
|
||||
echo ""
|
||||
echo " Clearing standby to restore $ACTIVE_NODE (undo the failover attempt)..."
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v off" 2>/dev/null || true
|
||||
na "crm_resource --cleanup" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Clear failure history ──────────────────────────────────────────────────
|
||||
echo "${DRY_PREFIX}Clearing Pacemaker failure history..."
|
||||
if ! $DRY_RUN; then
|
||||
ns "crm_resource --cleanup 2>/dev/null" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Re-enable original active node as standby ─────────────────────────────
|
||||
echo "${DRY_PREFIX}Re-enabling $ACTIVE_NODE (now standby — will not claim resources)..."
|
||||
if ! $DRY_RUN; then
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v off" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Wait briefly for DRBD resync to begin ─────────────────────────────────
|
||||
if ! $DRY_RUN; then
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# ── Final state ────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Failover complete. Final state:"
|
||||
echo ""
|
||||
|
||||
CRM_OUT_AFTER=""
|
||||
if ! $DRY_RUN; then
|
||||
CRM_OUT_AFTER=$(ns "crm_mon -1" 2>/dev/null || na "crm_mon -1" 2>/dev/null || true)
|
||||
else
|
||||
CRM_OUT_AFTER="$CRM_OUT"
|
||||
fi
|
||||
|
||||
NEW_ACTIVE=$(echo "$CRM_OUT_AFTER" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
if [[ -n "$NEW_ACTIVE" ]]; then
|
||||
if [[ "$NEW_ACTIVE" == "$ACTIVE_NODE" ]]; then
|
||||
echo " WARNING: $ACTIVE_NODE is still showing as active in crm_mon."
|
||||
echo " Pacemaker may still be settling — check again in a few seconds."
|
||||
else
|
||||
echo " Active: $NEW_ACTIVE"
|
||||
echo " Standby: $ACTIVE_NODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
RESOURCES_AFTER=$(echo "$CRM_OUT_AFTER" | awk '/Full List of Resources/,0' | tail -n +2 || true)
|
||||
[[ -z "$RESOURCES_AFTER" ]] && RESOURCES_AFTER=$(echo "$CRM_OUT_AFTER" | \
|
||||
grep -E 'Started|Stopped|Promoted|Unpromoted|FAILED|Master|Slave' || true)
|
||||
[[ -n "$RESOURCES_AFTER" ]] && echo "$RESOURCES_AFTER" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo " (DRBD resync of $ACTIVE_NODE may take a moment; monitor with:"
|
||||
echo " ssh nixos@${ACTIVE_IP} 'sudo watch -n3 cat /proc/drbd')"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fence_pve_ssh - Proxmox VE SSH fence agent for Pacemaker.
|
||||
|
||||
Uses SSH to reach the Proxmox host and run 'qm stop/start <vmid>'.
|
||||
Deploy to /etc/pacemaker/fence_pve_ssh on both HA nodes (chmod +x).
|
||||
|
||||
Configuration (as pacemaker stonith resource attributes):
|
||||
pve_host Proxmox host to SSH to (default: pve1.sweet.home)
|
||||
pve_user SSH user (default: wayne)
|
||||
key_file SSH private key path (default: /etc/fence-pve-ssh-key)
|
||||
vmid_node1 VMID for ha-server-1
|
||||
vmid_node2 VMID for ha-server-2
|
||||
plug Node name to act on (set by pacemaker: ha-server-1 or ha-server-2)
|
||||
action Action: off|on|reboot|status|list|metadata
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
METADATA = """<?xml version="1.0" ?>
|
||||
<resource-agent name="fence_pve_ssh" shortdesc="Proxmox VE SSH fence agent (test lab)">
|
||||
<longdesc>Fences a VM on a Proxmox VE host by SSHing to the PVE host and
|
||||
running qm stop/start. For test use only.</longdesc>
|
||||
<vendor-url>https://proxmox.com</vendor-url>
|
||||
<parameters>
|
||||
<parameter name="action" required="1" unique="0">
|
||||
<getopt mixed="-a, --action=[action]"/>
|
||||
<content type="string" default="reboot"/>
|
||||
<shortdesc lang="en">Fencing action: off|on|reboot|status|list</shortdesc>
|
||||
</parameter>
|
||||
<parameter name="plug" required="0" unique="0">
|
||||
<getopt mixed="-n, --plug=[nodename]"/>
|
||||
<content type="string"/>
|
||||
<shortdesc lang="en">Cluster node name to fence</shortdesc>
|
||||
</parameter>
|
||||
<parameter name="pve_host" required="0" unique="0">
|
||||
<getopt mixed="--pve-host=[host]"/>
|
||||
<content type="string" default="pve1.sweet.home"/>
|
||||
<shortdesc lang="en">Proxmox VE host to SSH to</shortdesc>
|
||||
</parameter>
|
||||
<parameter name="pve_user" required="0" unique="0">
|
||||
<getopt mixed="--pve-user=[user]"/>
|
||||
<content type="string" default="wayne"/>
|
||||
<shortdesc lang="en">SSH user on the Proxmox host</shortdesc>
|
||||
</parameter>
|
||||
<parameter name="key_file" required="0" unique="0">
|
||||
<getopt mixed="--key-file=[path]"/>
|
||||
<content type="string" default="/etc/fence-pve-ssh-key"/>
|
||||
<shortdesc lang="en">SSH private key file path</shortdesc>
|
||||
</parameter>
|
||||
<parameter name="vmid_node1" required="1" unique="0">
|
||||
<getopt mixed="--vmid-node1=[vmid]"/>
|
||||
<content type="string"/>
|
||||
<shortdesc lang="en">VMID for ha-test-node1</shortdesc>
|
||||
</parameter>
|
||||
<parameter name="vmid_node2" required="1" unique="0">
|
||||
<getopt mixed="--vmid-node2=[vmid]"/>
|
||||
<content type="string"/>
|
||||
<shortdesc lang="en">VMID for ha-test-node2</shortdesc>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<actions>
|
||||
<action name="off" timeout="60s"/>
|
||||
<action name="on" timeout="60s"/>
|
||||
<action name="reboot" timeout="60s"/>
|
||||
<action name="status" timeout="30s"/>
|
||||
<action name="list" timeout="10s"/>
|
||||
<action name="metadata" timeout="5s"/>
|
||||
</actions>
|
||||
</resource-agent>
|
||||
"""
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(add_help=False)
|
||||
p.add_argument("-a", "--action", default="reboot")
|
||||
p.add_argument("-n", "--plug")
|
||||
p.add_argument("--pve-host", default="pve1.sweet.home")
|
||||
p.add_argument("--pve-user", default="wayne")
|
||||
p.add_argument("--key-file", default="/etc/fence-pve-ssh-key")
|
||||
p.add_argument("--vmid-node1")
|
||||
p.add_argument("--vmid-node2")
|
||||
# Allow remaining unknown args (pacemaker may pass extra ones)
|
||||
return p.parse_known_args()[0]
|
||||
|
||||
|
||||
def ssh(pve_host, pve_user, key_file, cmd):
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-i", key_file,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=10",
|
||||
f"{pve_user}@{pve_host}",
|
||||
cmd,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_vmid(args):
|
||||
node = args.plug
|
||||
if not node:
|
||||
print("ERROR: --plug not specified", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
mapping = {
|
||||
"ha-server-1": args.vmid_node1,
|
||||
"ha-server-2": args.vmid_node2,
|
||||
}
|
||||
vmid = mapping.get(node)
|
||||
if not vmid:
|
||||
print(f"ERROR: unknown node '{node}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return vmid
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
action = args.action.lower()
|
||||
|
||||
if action == "metadata":
|
||||
print(METADATA)
|
||||
sys.exit(0)
|
||||
|
||||
if action == "list":
|
||||
if args.vmid_node1:
|
||||
print("ha-server-1")
|
||||
if args.vmid_node2:
|
||||
print("ha-server-2")
|
||||
sys.exit(0)
|
||||
|
||||
vmid = get_vmid(args)
|
||||
|
||||
if not os.path.exists(args.key_file):
|
||||
print(f"ERROR: SSH key not found at {args.key_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if action in ("off", "reboot"):
|
||||
print(f"Stopping VM {vmid} ({args.plug}) on {args.pve_host}...")
|
||||
r = ssh(args.pve_host, args.pve_user, args.key_file,
|
||||
f"sudo /usr/sbin/qm stop {vmid}")
|
||||
if r.returncode != 0:
|
||||
print(f"ERROR stopping VM: {r.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"VM {vmid} stopped")
|
||||
|
||||
if action in ("on", "reboot"):
|
||||
print(f"Starting VM {vmid} ({args.plug}) on {args.pve_host}...")
|
||||
r = ssh(args.pve_host, args.pve_user, args.key_file,
|
||||
f"sudo /usr/sbin/qm start {vmid}")
|
||||
if r.returncode != 0:
|
||||
print(f"ERROR starting VM: {r.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"VM {vmid} started")
|
||||
|
||||
if action == "status":
|
||||
r = ssh(args.pve_host, args.pve_user, args.key_file,
|
||||
f"sudo /usr/sbin/qm status {vmid}")
|
||||
if r.returncode != 0:
|
||||
print(f"ERROR querying VM status: {r.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# qm status returns "status: running" or "status: stopped"
|
||||
status_line = r.stdout.strip()
|
||||
print(status_line)
|
||||
if "stopped" in status_line:
|
||||
sys.exit(2) # pacemaker interprets exit 2 as "off"
|
||||
sys.exit(0) # running = exit 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
# health.sh — HA cluster health snapshot (read-only, non-destructive)
|
||||
#
|
||||
# Prints a compact status panel across both nodes: SSH reachability, quorum,
|
||||
# DRBD state, Pacemaker resources, and service ports via the VIP.
|
||||
# Run from any host with SSH access to the HA nodes.
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip
|
||||
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
|
||||
HA_USER="${HA_USER:-nixos}"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
REACHABLE_1=false
|
||||
REACHABLE_2=false
|
||||
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
probe_node() {
|
||||
local ip=$1
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${ip}" true 2>/dev/null && echo "ONLINE" || echo "OFFLINE"
|
||||
}
|
||||
|
||||
section() { echo ""; echo "── $* ──"; }
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Cluster Health — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Node reachability ──────────────────────────────────────────────────────
|
||||
section "Nodes"
|
||||
N1_STATUS=$(probe_node "$NODE1_IP")
|
||||
N2_STATUS=$(probe_node "$NODE2_IP")
|
||||
[[ "$N1_STATUS" == "ONLINE" ]] && REACHABLE_1=true
|
||||
[[ "$N2_STATUS" == "ONLINE" ]] && REACHABLE_2=true
|
||||
|
||||
if ! $REACHABLE_1 && ! $REACHABLE_2; then
|
||||
echo " ERROR: both nodes unreachable — cannot continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Detect active node ─────────────────────────────────────────────────────
|
||||
# crm_mon 2.x formats the Promoted line as " * Promoted: [ node ]" — the
|
||||
# bullet * means ^\s*(Promoted|Masters): never matches. Filter out Unpromoted
|
||||
# first, then match anywhere on the line.
|
||||
ACTIVE_NODE=""
|
||||
CRM_OUT=""
|
||||
if $REACHABLE_1; then
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || true)
|
||||
elif $REACHABLE_2; then
|
||||
CRM_OUT=$(n2 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
ACTIVE_NODE=$(echo "${CRM_OUT:-}" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
STANDBY_NODE=""
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
STANDBY_NODE="$NODE2"
|
||||
elif [[ "$ACTIVE_NODE" == "$NODE2" ]]; then
|
||||
STANDBY_NODE="$NODE1"
|
||||
fi
|
||||
|
||||
n1_tag=""; n2_tag=""
|
||||
[[ "$ACTIVE_NODE" == "$NODE1" ]] && n1_tag=" [ACTIVE]" || n1_tag=" [STANDBY]"
|
||||
[[ "$ACTIVE_NODE" == "$NODE2" ]] && n2_tag=" [ACTIVE]" || n2_tag=" [STANDBY]"
|
||||
[[ -z "$ACTIVE_NODE" ]] && { n1_tag=""; n2_tag=""; }
|
||||
|
||||
printf " %-14s [%s]%s\n" "$NODE1" "$N1_STATUS" "$n1_tag"
|
||||
printf " %-14s [%s]%s\n" "$NODE2" "$N2_STATUS" "$n2_tag"
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo ""
|
||||
echo " WARNING: could not determine active node from crm_mon."
|
||||
echo " Pacemaker may still be settling, or both nodes may be in standby."
|
||||
fi
|
||||
|
||||
# ── Quorum ─────────────────────────────────────────────────────────────────
|
||||
section "Quorum"
|
||||
if $REACHABLE_1; then
|
||||
QUORUM=$(n1 "corosync-quorumtool -s 2>/dev/null" 2>/dev/null || echo "")
|
||||
elif $REACHABLE_2; then
|
||||
QUORUM=$(n2 "corosync-quorumtool -s 2>/dev/null" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "${QUORUM:-}" ]]; then
|
||||
echo " corosync-quorumtool: unavailable"
|
||||
else
|
||||
QUORATE=$(echo "$QUORUM" | grep "Quorate:" | awk '{print $2}' || echo "?")
|
||||
VOTES=$(echo "$QUORUM" | grep "Total votes:" | awk '{print $3}' || echo "?")
|
||||
NEEDED=$(echo "$QUORUM" | grep "Quorum votes:" | awk '{print $3}' || echo "?")
|
||||
echo " Quorate: $QUORATE Votes: $VOTES / Expected: $NEEDED"
|
||||
fi
|
||||
|
||||
# ── DRBD ───────────────────────────────────────────────────────────────────
|
||||
section "DRBD (ha-data)"
|
||||
|
||||
drbd_info_from() {
|
||||
local node=$1 run=$2
|
||||
local role dstate cs pct
|
||||
role=$($run "drbdadm role ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
dstate=$($run "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
cs=$($run "grep -oE 'cs:[A-Za-z]+' /proc/drbd 2>/dev/null | head -1 | sed 's/cs://'" 2>/dev/null || echo "unknown")
|
||||
pct=$($run "grep -oE \"sync'ed:[[:space:]]+[0-9.]+\" /proc/drbd 2>/dev/null | grep -oE '[0-9.]+$' | head -1" 2>/dev/null || echo "")
|
||||
printf " %-14s role: %-22s dstate: %-25s cs: %s" \
|
||||
"$node" "${role:-unknown}" "${dstate:-unknown}" "${cs:-unknown}"
|
||||
[[ -n "$pct" ]] && printf " syncing: %s%%" "$pct"
|
||||
echo ""
|
||||
}
|
||||
|
||||
$REACHABLE_1 && drbd_info_from "$NODE1" n1 || echo " $NODE1 [OFFLINE]"
|
||||
$REACHABLE_2 && drbd_info_from "$NODE2" n2 || echo " $NODE2 [OFFLINE]"
|
||||
|
||||
# ── Pacemaker (full crm_mon output) ───────────────────────────────────────
|
||||
section "Pacemaker"
|
||||
if [[ -n "${CRM_OUT:-}" ]]; then
|
||||
echo "$CRM_OUT" | sed 's/^/ /'
|
||||
else
|
||||
echo " crm_mon returned no output — trying again without suppression:"
|
||||
if $REACHABLE_1; then
|
||||
n1 "crm_mon -1" || true
|
||||
elif $REACHABLE_2; then
|
||||
n2 "crm_mon -1" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── XFS mount ─────────────────────────────────────────────────────────────
|
||||
section "XFS Mount ($XFS_MOUNT)"
|
||||
check_mount() {
|
||||
local node=$1 run=$2
|
||||
local status
|
||||
if $run "mountpoint -q '$XFS_MOUNT' 2>/dev/null" 2>/dev/null; then
|
||||
local usage
|
||||
usage=$($run "df -h '$XFS_MOUNT' 2>/dev/null | tail -1 | awk '{print \$3\"/\"\$2\" used (\"\$5\")\";}'" 2>/dev/null || echo "")
|
||||
status="mounted"
|
||||
[[ -n "$usage" ]] && status="mounted $usage"
|
||||
else
|
||||
status="not mounted"
|
||||
fi
|
||||
printf " %-14s %s\n" "$node" "$status"
|
||||
}
|
||||
|
||||
$REACHABLE_1 && check_mount "$NODE1" n1 || echo " $NODE1 [OFFLINE]"
|
||||
$REACHABLE_2 && check_mount "$NODE2" n2 || echo " $NODE2 [OFFLINE]"
|
||||
|
||||
# ── LAN VIP (NFS) — reachable from workstation ────────────────────────────────
|
||||
section "LAN VIP ($VIP_LAN) — NFS"
|
||||
if ping -c1 -W2 "$VIP_LAN" >/dev/null 2>&1; then
|
||||
echo " Ping OK"
|
||||
else
|
||||
echo " Ping UNREACHABLE"
|
||||
fi
|
||||
if bash -c "echo >/dev/tcp/${VIP_LAN}/2049" 2>/dev/null; then
|
||||
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 "════════════════════════════════════════════════════"
|
||||
if [[ -n "$ACTIVE_NODE" ]]; then
|
||||
echo " Active: $ACTIVE_NODE Standby: $STANDBY_NODE"
|
||||
else
|
||||
echo " Active: unknown (Pacemaker not settled)"
|
||||
fi
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
# resize-data-disk.sh — online resize of the HA cluster data disk
|
||||
#
|
||||
# Three-phase process (all online-safe, no downtime required):
|
||||
# 1. Proxmox: grow scsi1 on both HA VMs (qm resize)
|
||||
# 2. Guest: rescan block device on both nodes so the kernel sees the new size
|
||||
# 3. DRBD + XFS: drbdadm resize, then xfs_growfs — active node only
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ha/resize-data-disk.sh --size +20G [--force] [--dry-run]
|
||||
#
|
||||
# --size +NNg amount to grow scsi1 by, e.g. +20G, +50G (required)
|
||||
# XFS and DRBD cannot shrink; only positive deltas accepted
|
||||
# --force skip the interactive confirmation prompt
|
||||
# --dry-run show what would be done without changing anything
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../env.sh
|
||||
source "${SCRIPT_DIR}/../env.sh"
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}"
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}"
|
||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}"
|
||||
DRBD_RESOURCE="${DRBD_RESOURCE:-ha-data}"
|
||||
DATA_DISK_SLOT="${DATA_DISK_SLOT:-scsi1}" # Proxmox disk name (scsi1 = data disk)
|
||||
HA_USER="${HA_USER:-nixos}"
|
||||
PVE_HOST="${PVE_HOST:-${PVE1_HOST}}"
|
||||
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.
|
||||
# matches variables.nix's haServerDrbdDisk.
|
||||
DATA_DISK_BYID="${DATA_DISK_BYID:-scsi-0QEMU_QEMU_HARDDISK_drive-${DATA_DISK_SLOT}}"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pve() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 \
|
||||
"${PVE_SSH_USER}@${PVE_HOST}" "$@"; }
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────
|
||||
SIZE=""
|
||||
FORCE=false
|
||||
DRY_RUN=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--size) shift; SIZE="${1:?--size requires a value (e.g. +20G)}" ;;
|
||||
--force) FORCE=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
*) echo "Unknown argument: $1"
|
||||
echo "Usage: $0 --size +NNg [--force] [--dry-run]"
|
||||
exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$SIZE" ]]; then
|
||||
echo "ERROR: --size is required (e.g. --size +20G)"
|
||||
echo "Usage: $0 --size +NNg [--force] [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only positive deltas — qm resize, DRBD, and XFS all refuse to shrink.
|
||||
if [[ ! "$SIZE" =~ ^\+[0-9]+(G|M|T)$ ]]; then
|
||||
echo "ERROR: --size must be a positive delta like +20G, +50G, +500M, +2T"
|
||||
echo " (qm resize, drbdadm resize, and xfs_growfs can only grow, not shrink)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DRY_PREFIX=""
|
||||
$DRY_RUN && DRY_PREFIX="[dry-run] "
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Data Disk Resize — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo " Size delta: $SIZE • Proxmox: $PVE_HOST"
|
||||
$DRY_RUN && echo " MODE: dry-run — no changes will be made"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Detect active node ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Detecting active node..."
|
||||
|
||||
CRM_OUT=""
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE1_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$CRM_OUT" ]]; then
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE2_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n2 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# crm_mon 2.x formats Promoted lines as " * Promoted: [ node ]" (bullet *),
|
||||
# so ^\s*(Promoted|Masters): never matches; filter Unpromoted first instead.
|
||||
ACTIVE_NODE=$(echo "$CRM_OUT" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo "ERROR: could not determine active node from crm_mon."
|
||||
echo " Is Pacemaker still settling? Try running scripts/ha/health.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
ACTIVE_IP="$NODE1_IP"
|
||||
na() { n1 "$@"; }
|
||||
else
|
||||
ACTIVE_IP="$NODE2_IP"
|
||||
na() { n2 "$@"; }
|
||||
fi
|
||||
|
||||
echo " Active node: $ACTIVE_NODE ($ACTIVE_IP)"
|
||||
|
||||
# ── Pre-check DRBD state ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Pre-checks..."
|
||||
|
||||
DRBD_DSTATE=$(na "drbdadm dstate ${DRBD_RESOURCE} 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if ! echo "$DRBD_DSTATE" | grep -q "UpToDate/UpToDate"; then
|
||||
echo " WARNING: DRBD dstate is '$DRBD_DSTATE' (expected UpToDate/UpToDate)."
|
||||
echo " Resizing with a partially-synced disk may cause issues."
|
||||
if ! $FORCE; then
|
||||
echo " Use --force to proceed anyway."
|
||||
exit 1
|
||||
fi
|
||||
echo " --force specified — proceeding despite non-ideal DRBD state."
|
||||
else
|
||||
echo " DRBD dstate: $DRBD_DSTATE — OK"
|
||||
fi
|
||||
|
||||
# ── Find VMIDs on Proxmox ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Looking up VM IDs on ${PVE_HOST}..."
|
||||
|
||||
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)
|
||||
VMID2=$(echo "$QM_LIST" | awk -v name="$NODE2" '$0 ~ name {print $1}' | head -1)
|
||||
|
||||
if [[ -z "$VMID1" ]]; then
|
||||
echo " ERROR: could not find VMID for $NODE1 on $PVE_HOST"
|
||||
echo " qm list output:"
|
||||
echo "$QM_LIST" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$VMID2" ]]; then
|
||||
echo " ERROR: could not find VMID for $NODE2 on $PVE_HOST"
|
||||
echo " qm list output:"
|
||||
echo "$QM_LIST" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " $NODE1: VMID $VMID1"
|
||||
echo " $NODE2: VMID $VMID2"
|
||||
|
||||
# ── Confirm ────────────────────────────────────────────────────────────────
|
||||
if ! $FORCE && ! $DRY_RUN; then
|
||||
echo ""
|
||||
echo " Plan:"
|
||||
echo " Phase 1 — qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE} (on $PVE_HOST)"
|
||||
echo " qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE} (on $PVE_HOST)"
|
||||
echo " Phase 2 — block device rescan on $NODE1 and $NODE2"
|
||||
echo " Phase 3 — drbdadm resize + xfs_growfs on $ACTIVE_NODE"
|
||||
echo " No downtime required (all operations are online-safe)."
|
||||
printf " Proceed? [y/N] "
|
||||
read -r ANSWER
|
||||
[[ "${ANSWER,,}" == "y" || "${ANSWER,,}" == "yes" ]] || { echo "Aborted."; exit 0; }
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Phase 1 — Resize both VM data disks in Proxmox
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "── Phase 1 — Proxmox disk resize (${DATA_DISK_SLOT} ${SIZE} on both VMs) ──"
|
||||
|
||||
echo " ${DRY_PREFIX}qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE} ($NODE1 on ${PVE_HOST})"
|
||||
if ! $DRY_RUN; then
|
||||
pve "$PVE_SUDO qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE}"
|
||||
fi
|
||||
|
||||
echo " ${DRY_PREFIX}qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE} ($NODE2 on ${PVE_HOST})"
|
||||
if ! $DRY_RUN; then
|
||||
pve "$PVE_SUDO qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE}"
|
||||
fi
|
||||
|
||||
echo " Phase 1 done."
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Phase 2 — Rescan block device on both guest nodes
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "── Phase 2 — Block device rescan (both nodes) ──"
|
||||
|
||||
rescan_node() {
|
||||
local node_name=$1 run_fn=$2
|
||||
|
||||
# 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=""
|
||||
blk_dev=$($run_fn "bash -c 'basename \$(readlink -f /dev/disk/by-id/${DATA_DISK_BYID})'" 2>/dev/null || true)
|
||||
if [[ -z "$blk_dev" ]]; then
|
||||
echo " ERROR: /dev/disk/by-id/${DATA_DISK_BYID} not found on $node_name" >&2
|
||||
echo " Check DATA_DISK_BYID or DATA_DISK_SLOT configuration." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ${DRY_PREFIX}Rescanning /dev/${blk_dev} on ${node_name}..."
|
||||
if ! $DRY_RUN; then
|
||||
$run_fn "bash -c 'echo 1 > /sys/block/${blk_dev}/device/rescan'" 2>/dev/null
|
||||
local new_size
|
||||
new_size=$($run_fn "lsblk -nd -o SIZE /dev/${blk_dev} 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
echo " /dev/${blk_dev} on $node_name now reports: $new_size"
|
||||
fi
|
||||
}
|
||||
|
||||
rescan_node "$NODE1" n1
|
||||
rescan_node "$NODE2" n2
|
||||
|
||||
echo " Phase 2 done."
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Phase 3 — Grow DRBD metadata, then XFS (active node only)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "── Phase 3 — DRBD resize + XFS grow (on active node: $ACTIVE_NODE) ──"
|
||||
|
||||
echo " ${DRY_PREFIX}drbdadm resize ${DRBD_RESOURCE}"
|
||||
if ! $DRY_RUN; then
|
||||
na "drbdadm resize ${DRBD_RESOURCE}"
|
||||
fi
|
||||
|
||||
echo " ${DRY_PREFIX}xfs_growfs ${XFS_MOUNT}"
|
||||
if ! $DRY_RUN; then
|
||||
na "xfs_growfs ${XFS_MOUNT}"
|
||||
fi
|
||||
|
||||
echo " Phase 3 done."
|
||||
|
||||
# ── Verify ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Verify ──"
|
||||
|
||||
if ! $DRY_RUN; then
|
||||
DF_OUT=$(na "df -h '${XFS_MOUNT}' 2>/dev/null" 2>/dev/null || echo "")
|
||||
if [[ -n "$DF_OUT" ]]; then
|
||||
echo " ${XFS_MOUNT}:"
|
||||
echo "$DF_OUT" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
DRBD_DSTATE_AFTER=$(na "drbdadm dstate ${DRBD_RESOURCE} 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
echo " DRBD dstate: $DRBD_DSTATE_AFTER"
|
||||
if ! echo "$DRBD_DSTATE_AFTER" | grep -q "UpToDate/UpToDate"; then
|
||||
echo " NOTE: DRBD is resyncing — normal immediately after resize."
|
||||
echo " Monitor: ssh nixos@${ACTIVE_IP} 'sudo watch -n3 cat /proc/drbd'"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would verify df -h ${XFS_MOUNT} and drbdadm dstate on $ACTIVE_NODE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " Resize complete."
|
||||
echo " Active node: $ACTIVE_NODE"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
# Add a NixOS host to the FreeIPA domain and produce a sops-encrypted keytab
|
||||
# at secrets/<hostname>.keytab, ready for modules/ipa/client.nix.
|
||||
#
|
||||
# One command replaces three error-prone manual steps:
|
||||
# 1. ipa host-add on the domain controller
|
||||
# 2. ipa-getkeytab on the domain controller + SCP back
|
||||
# 3. sops encrypt in-place (must be at secrets/<hostname>.keytab for
|
||||
# the creation rule to match -- the common mistake that breaks sops)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ipa/create-nixos-ipa-host-account.sh [options] <hostname>
|
||||
#
|
||||
# Arguments:
|
||||
# <hostname> Short hostname, e.g. "tailscale-router". The FQDN is
|
||||
# derived as <hostname>.<HOME_DOMAIN>.
|
||||
#
|
||||
# Options:
|
||||
# --ip <addr> Register this IP with the IPA host record (optional).
|
||||
# --dc <host> SSH to this host to run IPA commands.
|
||||
# Default: $IPA_SERVER (from env.sh / environment).
|
||||
# --dc-user <u> SSH user on the domain controller. Default: wayne.
|
||||
# --dry-run Print what would be done without making any changes.
|
||||
# -h, --help Show this message.
|
||||
#
|
||||
# Prereqs:
|
||||
# 1. Run from the repo root (so .sops.yaml and secrets/ are found).
|
||||
# 2. SSH access to the domain controller as --dc-user (default: wayne)
|
||||
# with passwordless sudo (or sudo cached). IPA commands and kinit run
|
||||
# as root via sudo so the Kerberos ticket is in root's cache where all
|
||||
# ipa tools expect it. If there's no valid ticket, the script runs
|
||||
# `sudo kinit admin` interactively — you'll be prompted for the IPA
|
||||
# admin password once. The password never touches this script.
|
||||
# 3. The host's age key(s) must already be in .sops.yaml. Run
|
||||
# scripts/secrets/sync-host-keys.sh <flake-target> first so the host
|
||||
# can decrypt its own keytab on boot. This script adds the .sops.yaml
|
||||
# creation rule for secrets/<hostname>.keytab automatically, but the
|
||||
# host age key anchor (&lxc-<hostname> etc.) must already exist —
|
||||
# otherwise only the admin key can decrypt the keytab and the deployed
|
||||
# host will fail to read it.
|
||||
# 4. sops in PATH, or Nix available to run it via `nix run`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# shellcheck source=../env.sh
|
||||
source "${SCRIPT_DIR}/../env.sh"
|
||||
|
||||
# --- Argument parsing ---
|
||||
|
||||
DC_HOST="${IPA_SERVER}"
|
||||
DC_USER="wayne"
|
||||
IP_ADDR=""
|
||||
DRY_RUN=false
|
||||
TARGET=""
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \?//; p } }' "$0"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ip) IP_ADDR="$2"; shift 2 ;;
|
||||
--dc) DC_HOST="$2"; shift 2 ;;
|
||||
--dc-user) DC_USER="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
-*) echo "Unknown flag: $1" >&2; usage 1 ;;
|
||||
*)
|
||||
if [[ -n "${TARGET}" ]]; then echo "Unexpected argument: $1" >&2; usage 1; fi
|
||||
TARGET="$1"; shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${TARGET}" ]]; then
|
||||
echo "Error: hostname required." >&2
|
||||
usage 1
|
||||
fi
|
||||
|
||||
# Reject FQDNs passed by mistake — the script appends HOME_DOMAIN itself.
|
||||
# "nixos.sweet.home" → FQDN would become "nixos.sweet.home.sweet.home".
|
||||
if [[ "${TARGET}" == *"."* ]]; then
|
||||
echo "Error: <hostname> must be the short name (e.g. 'nixos'), not a FQDN." >&2
|
||||
echo " The FQDN is derived automatically as ${TARGET}.${HOME_DOMAIN}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FQDN="${TARGET}.${HOME_DOMAIN}"
|
||||
KEYTAB_SECRET="${REPO_ROOT}/secrets/${TARGET}.keytab"
|
||||
# Temp path on the domain controller — use a name that won't collide.
|
||||
DC_TMP="/tmp/nixos-keytab-${TARGET}-$$.keytab"
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
logn() { echo " $*"; }
|
||||
|
||||
run() {
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
dc_run() {
|
||||
# Run a command string on the domain controller via SSH.
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] ssh ${DC_USER}@${DC_HOST} $*"
|
||||
else
|
||||
ssh "${DC_USER}@${DC_HOST}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Locate sops ---
|
||||
|
||||
if command -v sops &>/dev/null; then
|
||||
SOPS_CMD=(sops)
|
||||
else
|
||||
log "sops not in PATH — will use 'nix run nixpkgs#sops'"
|
||||
SOPS_CMD=(nix run "nixpkgs#sops" --)
|
||||
fi
|
||||
|
||||
# --- Preflight checks ---
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
[[ -f .sops.yaml ]] || { echo "Error: .sops.yaml not found — run from repo root." >&2; exit 1; }
|
||||
[[ -d secrets ]] || { echo "Error: secrets/ not found — run from repo root." >&2; exit 1; }
|
||||
|
||||
# --- Step 1: Ensure .sops.yaml has a creation rule for this keytab ---
|
||||
#
|
||||
# sops matches creation rules against the PATH of the file being encrypted,
|
||||
# not the output path. To match secrets/<hostname>.keytab, the file must
|
||||
# already be at that path when sops -e -i is called. The creation rule must
|
||||
# also exist at that point or sops will refuse with "no matching creation
|
||||
# rules found."
|
||||
|
||||
log "Checking .sops.yaml for creation rule: secrets/${TARGET}.keytab"
|
||||
|
||||
RULE_EXISTS=false
|
||||
# Match "path_regex: secrets/<hostname>...keytab" — using .*keytab rather
|
||||
# than \.keytab because the file stores the regex verbatim (\.keytab = two
|
||||
# chars: backslash + dot), which a BRE \. (= escaped literal dot) won't span.
|
||||
if grep -q "path_regex: secrets/${TARGET}.*keytab" .sops.yaml 2>/dev/null; then
|
||||
RULE_EXISTS=true
|
||||
logn "Rule already exists — skipping addition."
|
||||
fi
|
||||
|
||||
if ! $RULE_EXISTS; then
|
||||
# Collect which platform-variant age anchors exist in .sops.yaml for this
|
||||
# hostname. The keytab is platform-agnostic (same FQDN regardless of
|
||||
# whether lxc/proxmox/linode variant is deployed), so all platform anchors
|
||||
# that have been registered get added as recipients.
|
||||
RECIPIENTS=("*admin")
|
||||
for platform in lxc proxmox linode; do
|
||||
anchor="${platform}-${TARGET}"
|
||||
if grep -q "^ - &${anchor} " .sops.yaml; then
|
||||
RECIPIENTS+=("*${anchor}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#RECIPIENTS[@]} -eq 1 ]]; then
|
||||
echo "Warning: no platform age keys found for '${TARGET}' in .sops.yaml." >&2
|
||||
echo " Run scripts/secrets/sync-host-keys.sh <flake-target> first," >&2
|
||||
echo " otherwise only the admin key can decrypt the keytab and the" >&2
|
||||
echo " deployed host won't be able to read it at boot." >&2
|
||||
echo " Continuing with admin-only encryption..." >&2
|
||||
fi
|
||||
|
||||
# Build the indented recipient list for the YAML block.
|
||||
RECIPIENT_YAML=""
|
||||
for r in "${RECIPIENTS[@]}"; do
|
||||
RECIPIENT_YAML+=" - ${r}"$'\n'
|
||||
done
|
||||
RECIPIENT_YAML="${RECIPIENT_YAML%$'\n'}" # strip trailing newline
|
||||
|
||||
NEW_RULE="
|
||||
# Host keytab for ${TARGET} FreeIPA enrollment (binary sops file).
|
||||
# Generated by scripts/ipa/create-nixos-ipa-host-account.sh.
|
||||
- path_regex: secrets/${TARGET}\\.keytab\$
|
||||
key_groups:
|
||||
- age:
|
||||
${RECIPIENT_YAML}"
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] Would append to .sops.yaml:"
|
||||
echo "${NEW_RULE}"
|
||||
else
|
||||
logn "Adding creation rule (recipients: ${RECIPIENTS[*]})"
|
||||
printf '%s\n' "${NEW_RULE}" >> .sops.yaml
|
||||
logn "Added."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Step 2: Add IPA host account (idempotent) ---
|
||||
|
||||
log "Adding FreeIPA host account: ${FQDN}"
|
||||
|
||||
# Ensure there's a valid admin Kerberos ticket on the DC.
|
||||
# ipa host-add and ipa-getkeytab both need one. All IPA commands run via
|
||||
# sudo so the ticket must be in root's cache — check and refresh as root.
|
||||
# ssh -t allocates a PTY so kinit (and sudo if needed) can prompt normally;
|
||||
# no password ever touches this script or the shell history.
|
||||
if ! $DRY_RUN; then
|
||||
if ! ssh "${DC_USER}@${DC_HOST}" "sudo klist -s" &>/dev/null; then
|
||||
log "No valid Kerberos ticket on ${DC_HOST} — running sudo kinit admin"
|
||||
ssh -t "${DC_USER}@${DC_HOST}" "sudo kinit admin"
|
||||
if ! ssh "${DC_USER}@${DC_HOST}" "sudo klist -s" &>/dev/null; then
|
||||
echo "Error: kinit admin failed or produced no valid ticket." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
logn "Kerberos ticket on ${DC_HOST} is valid."
|
||||
fi
|
||||
fi
|
||||
|
||||
IP_FLAG=""
|
||||
[[ -n "${IP_ADDR}" ]] && IP_FLAG="--ip-address=${IP_ADDR}"
|
||||
|
||||
# --force: create the host record even if DNS doesn't resolve it yet.
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] ssh ${DC_USER}@${DC_HOST} sudo ipa host-add '${FQDN}' ${IP_FLAG} --force"
|
||||
else
|
||||
HOST_ADD_OUT=$(ssh "${DC_USER}@${DC_HOST}" "sudo ipa host-add '${FQDN}' ${IP_FLAG} --force 2>&1") \
|
||||
&& HOST_ADD_RC=0 || HOST_ADD_RC=$?
|
||||
if [[ $HOST_ADD_RC -eq 0 ]]; then
|
||||
echo "${HOST_ADD_OUT}"
|
||||
elif echo "${HOST_ADD_OUT}" | grep -q "already exists"; then
|
||||
logn "(host already registered)"
|
||||
else
|
||||
echo "Error: ipa host-add failed (exit ${HOST_ADD_RC}):" >&2
|
||||
echo "${HOST_ADD_OUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Step 3: Fetch the keytab from the domain controller ---
|
||||
|
||||
log "Fetching keytab for host/${FQDN}"
|
||||
|
||||
# Remove the plaintext keytab if the script aborts before encryption completes.
|
||||
# The trap is cleared at the end of step 4 once sops has encrypted it in-place.
|
||||
trap 'rm -f "${KEYTAB_SECRET}"' EXIT
|
||||
|
||||
dc_run "sudo ipa-getkeytab -s '${IPA_SERVER}' -p 'host/${FQDN}' -k '${DC_TMP}'"
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] Would stream ${DC_USER}@${DC_HOST}:${DC_TMP} → secrets/${TARGET}.keytab"
|
||||
else
|
||||
logn "Streaming keytab from ${DC_HOST}:${DC_TMP} → secrets/${TARGET}.keytab"
|
||||
# scp can't read a root-owned temp file as ${DC_USER}; pipe through sudo cat instead.
|
||||
ssh "${DC_USER}@${DC_HOST}" "sudo cat '${DC_TMP}'" > "${KEYTAB_SECRET}"
|
||||
|
||||
logn "Removing temp file on ${DC_HOST}"
|
||||
dc_run "sudo rm -f '${DC_TMP}'"
|
||||
fi
|
||||
|
||||
# --- Step 4: Encrypt in-place ---
|
||||
#
|
||||
# The file must already be at secrets/<hostname>.keytab (done above) so
|
||||
# sops matches the creation rule by path. Using -i (in-place) rather than
|
||||
# stdout redirect keeps the path intact through the encrypt call.
|
||||
|
||||
log "Encrypting secrets/${TARGET}.keytab in-place with sops"
|
||||
run "${SOPS_CMD[@]}" -e --input-type binary -i "${KEYTAB_SECRET}"
|
||||
|
||||
# Encryption succeeded — the file is now sops-encrypted; cancel the cleanup trap.
|
||||
trap - EXIT
|
||||
|
||||
# --- Done ---
|
||||
|
||||
if ! $DRY_RUN; then
|
||||
echo ""
|
||||
echo "Done. secrets/${TARGET}.keytab is sops-encrypted and ready."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Verify: grep '\"data\": \"ENC' secrets/${TARGET}.keytab"
|
||||
echo " 2. Stage and commit:"
|
||||
echo " git add secrets/${TARGET}.keytab .sops.yaml"
|
||||
echo " git commit -m 'secrets: add IPA keytab for ${TARGET}'"
|
||||
echo " 3. Add to hosts/${TARGET}/host.nix (networking block and imports):"
|
||||
echo ""
|
||||
echo " networking = {"
|
||||
echo " hostName = \"${TARGET}\";"
|
||||
echo " domain = vars.homeDomain; # required for Kerberos FQDN"
|
||||
echo " nameservers = [ vars.domainControllerIp ]; # IPA DNS"
|
||||
echo " ..."
|
||||
echo " };"
|
||||
echo ""
|
||||
echo " imports = ["
|
||||
echo " (import ../../modules/ipa/client.nix {"
|
||||
echo " keytabSopsFile = ../../secrets/${TARGET}.keytab;"
|
||||
echo " caCertFile = ../../certs/ipa-ca.crt;"
|
||||
echo " })"
|
||||
echo " ];"
|
||||
echo ""
|
||||
echo " 4. Deploy: nixos-rebuild switch (or create-proxmox-resource.sh)"
|
||||
fi
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# Clan vars helpers: manage SSH host keys stored as clan vars (sops-encrypted
|
||||
# binary files under vars/per-machine/<target>/openssh/) instead of the
|
||||
# gitignored host-keys/ directory.
|
||||
#
|
||||
# Layout (per clan's convention):
|
||||
# vars/per-machine/<target>/openssh/ssh_host_ed25519_key/secret -- sops binary (admin-encrypted)
|
||||
# vars/per-machine/<target>/openssh/ssh_host_ed25519_key.pub/value -- plaintext SSH pubkey
|
||||
#
|
||||
# Sourced by create-proxmox-resource.sh and sync-host-keys.sh.
|
||||
# Depends on sops-age.sh and ssh-host-keys.sh being sourced first (for
|
||||
# sops_yaml_admin_pubkey, ssh_pubkey_to_age, and NIX_OPTS).
|
||||
|
||||
if ! declare -p NIX_OPTS >/dev/null 2>&1; then
|
||||
declare -a NIX_OPTS=()
|
||||
fi
|
||||
|
||||
# clan_ssh_key_exists <target> <repo_root>
|
||||
# Returns 0 if clan vars hold a SSH host key for <target>, 1 otherwise.
|
||||
clan_ssh_key_exists() {
|
||||
local target="$1" repo_root="$2"
|
||||
[[ -f "${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key/secret" ]]
|
||||
}
|
||||
|
||||
# clan_ssh_pubkey_path <target> <repo_root>
|
||||
# Prints the path to the plaintext SSH public key value file.
|
||||
clan_ssh_pubkey_path() {
|
||||
local target="$1" repo_root="$2"
|
||||
echo "${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key.pub/value"
|
||||
}
|
||||
|
||||
# clan_decrypt_ssh_key <target> <repo_root> <dest_dir>
|
||||
# Decrypts the sops-encrypted SSH host private key for <target> into <dest_dir>,
|
||||
# naming it <target>_ssh_host_ed25519_key (to match NIXOS_HOST_KEYS_DIR
|
||||
# conventions that lxc.nix and the disko build already expect). Also copies
|
||||
# the plaintext public key. The caller is responsible for protecting and
|
||||
# cleaning up <dest_dir>.
|
||||
clan_decrypt_ssh_key() {
|
||||
local target="$1" repo_root="$2" dest_dir="$3"
|
||||
local secret="${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key/secret"
|
||||
local pubval="${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key.pub/value"
|
||||
local dest_priv="${dest_dir}/${target}_ssh_host_ed25519_key"
|
||||
local dest_pub="${dest_dir}/${target}_ssh_host_ed25519_key.pub"
|
||||
|
||||
nix-shell "${NIX_OPTS[@]}" -p sops --run \
|
||||
"sops -d --output-type binary '${secret}'" > "$dest_priv"
|
||||
chmod 0600 "$dest_priv"
|
||||
cp "$pubval" "$dest_pub"
|
||||
}
|
||||
|
||||
# clan_generate_ssh_key <target> <repo_root>
|
||||
# Generates a new SSH host key pair and stores it in clan vars format:
|
||||
# - private key: sops binary-encrypted for the admin age key
|
||||
# - public key: plaintext value file
|
||||
# Idempotent: if the secret already exists, prints a note and returns 0.
|
||||
# Requires sops_yaml_admin_pubkey (from sops-age.sh) to be available.
|
||||
clan_generate_ssh_key() {
|
||||
local target="$1" repo_root="$2"
|
||||
local var_base="${repo_root}/vars/per-machine/${target}/openssh"
|
||||
local secret_dir="${var_base}/ssh_host_ed25519_key"
|
||||
local pubval_dir="${var_base}/ssh_host_ed25519_key.pub"
|
||||
|
||||
if [[ -f "${secret_dir}/secret" ]]; then
|
||||
echo "Clan SSH host key for ${target} already exists -- skipping generation."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Resolve admin age public key from .sops.yaml
|
||||
local admin_pubkey
|
||||
admin_pubkey="$(sops_yaml_admin_pubkey "${repo_root}/.sops.yaml")"
|
||||
if [[ -z "$admin_pubkey" ]]; then
|
||||
echo "ERROR: Could not find &admin age key in ${repo_root}/.sops.yaml" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Generate the SSH key pair in a secure temp directory
|
||||
local tmpdir
|
||||
tmpdir="$(mktemp -d)"
|
||||
local priv_tmp="${tmpdir}/ssh_host_ed25519_key"
|
||||
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -rf '${tmpdir}'" RETURN
|
||||
|
||||
nix-shell "${NIX_OPTS[@]}" -p openssh --run \
|
||||
"ssh-keygen -t ed25519 -N '' -C '${target}' -f '${priv_tmp}'" >/dev/null
|
||||
|
||||
# Create a minimal sops config that uses only the admin age key -- this
|
||||
# prevents sops from merging in ALL recipients from .sops.yaml (which
|
||||
# would unnecessarily encrypt for every host's key, not just admin).
|
||||
local sops_cfg="${tmpdir}/sops-config.json"
|
||||
printf '{"creation_rules":[{"key_groups":[{"age":["%s"]}]}]}\n' \
|
||||
"$admin_pubkey" > "$sops_cfg"
|
||||
|
||||
# Encrypt the private key in sops binary format (admin-only recipient)
|
||||
mkdir -p "$secret_dir" "$pubval_dir"
|
||||
nix-shell "${NIX_OPTS[@]}" -p sops --run \
|
||||
"sops -e --config '${sops_cfg}' --input-type binary '${priv_tmp}'" \
|
||||
> "${secret_dir}/secret"
|
||||
|
||||
# Store the public key as a plaintext value file
|
||||
cp "${priv_tmp}.pub" "${pubval_dir}/value"
|
||||
|
||||
echo "Generated and stored clan SSH host key for ${target}."
|
||||
echo " Private key: ${secret_dir}/secret (sops binary, admin-key encrypted)"
|
||||
echo " Public key: ${pubval_dir}/value"
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
: "${NIX_CACHE_HOST:=nix-cache}"
|
||||
: "${NIX_CACHE_HOST_KEY:=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPeWgMsdaiz4axT/deFc1+0B5bN+GX/NOeW9bbQ0c/IT lxc-nix-cache}"
|
||||
: "${NIX_CACHE_HOST_KEY:=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICuHUxGNH6ei3BZD+EfZs3l4X8uJNcjQiOsM/G4yo4O/ lxc-nix-cache}"
|
||||
: "${REMOTE_BUILDER_USER:=nixremote}"
|
||||
|
||||
CACHE_PUB_KEY="cache.local-1:usoWYanY3Kpq2+kDIS2nhWoLZiRxanmdysdzqCFBHW4="
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
# script -- there's no multi-gigabyte image to transfer afterward. The first
|
||||
# time a node doesn't have that repo path yet, it's bootstrapped: cloned from
|
||||
# this checkout's own `origin` remote, then scripts/codex-setup.sh installs
|
||||
# the build tooling (Nix, etc.). Every run after that just `git pull`s it and
|
||||
# copies over the locally-managed host-keys/ (gitignored, so a git pull
|
||||
# alone wouldn't carry it) before building.
|
||||
# the build tooling (Nix, etc.). Every run after that just `git pull`s it.
|
||||
# SSH host keys are stored as clan vars (vars/per-machine/<target>/openssh/,
|
||||
# committed and sops-encrypted) -- the script decrypts them locally and
|
||||
# copies only the two files for this target to the node's host-keys/ before
|
||||
# building. A target with no clan var is an error (generate one first with
|
||||
# scripts/secrets/sync-host-keys.sh <target>).
|
||||
#
|
||||
# --node (default: $PROXMOX_HOST, see scripts/env.sh) picks which of the two
|
||||
# LAN Proxmox nodes this runs against: production, pve1.sweet.home
|
||||
@@ -59,6 +62,10 @@ source "${repo_root}/scripts/env.sh"
|
||||
source "${repo_root}/scripts/lib/nix-eval.sh"
|
||||
# shellcheck source=../lib/confirm.sh
|
||||
source "${repo_root}/scripts/lib/confirm.sh"
|
||||
# shellcheck source=../lib/sops-age.sh
|
||||
source "${repo_root}/scripts/lib/sops-age.sh"
|
||||
# shellcheck source=../lib/clan-vars.sh
|
||||
source "${repo_root}/scripts/lib/clan-vars.sh"
|
||||
|
||||
sync_keys="${repo_root}/scripts/secrets/sync-host-keys.sh"
|
||||
|
||||
@@ -295,6 +302,12 @@ platform_prefix="lxc"
|
||||
[[ -z "$cores" ]] && cores="$PROXMOX_DEFAULT_CORES"
|
||||
[[ -z "$memory" ]] && memory="$PROXMOX_DEFAULT_MEMORY_MB"
|
||||
|
||||
if [[ "$type" == "vm" && -n "$disk_size" ]]; then
|
||||
echo "WARNING: --disk-size is LXC-only for create mode and is ignored for VMs." >&2
|
||||
echo " VM disk size comes from proxmoxImageSize in variables.nix (currently ${disk_size}G was requested)." >&2
|
||||
echo " To expand after creation, use: --modify --vmid <n> --grow-disk <GB>" >&2
|
||||
fi
|
||||
|
||||
# --- discover / resolve the flake target from --host --------------------
|
||||
# Emits "<target>\t<hostName>" pairs for every ${platform_prefix}-* flake
|
||||
# target -- the one source both --list and the --host lookup below read
|
||||
@@ -347,6 +360,14 @@ fi
|
||||
# feeds straight into the guest's real hostname) disagree with host.nix.
|
||||
[[ -z "$name" ]] && name="$host"
|
||||
|
||||
# For VM builds: the diskoImagesScript (run via QEMU on the node) writes the
|
||||
# raw disk image as <hostname>.raw into the CWD it was called from (the remote
|
||||
# repo dir), not to /var/lib/vz/import/ or anywhere else. Import directly from
|
||||
# there -- no intermediate mv that can fail crossing filesystem boundaries or
|
||||
# leave a stale file on error.
|
||||
vm_built_raw=""
|
||||
[[ "$type" == "vm" ]] && vm_built_raw="${remote_repo_dir}/${host}.raw"
|
||||
|
||||
# --- refuse to duplicate a host that's already live on the node ---------
|
||||
# Queries the node itself (qm/pct's own name/hostname config), not any
|
||||
# static list in this repo -- a file can't track whether a resource still
|
||||
@@ -485,12 +506,37 @@ echo "Target: ${flake_target} (host=${host}, type=${type}) -> Proxmox resource '
|
||||
nix_extra_opts
|
||||
|
||||
# --- make sure this target has a registered host key --------------------
|
||||
# sync-host-keys.sh is idempotent and generates the key (via clan vars) if
|
||||
# no key exists yet -- the old inline prepare-host-key.sh call is gone.
|
||||
echo
|
||||
echo "==> Ensuring host key exists and is registered..."
|
||||
sync_args=("$flake_target")
|
||||
[[ "$dry_run" -eq 1 ]] && sync_args+=(--dry-run)
|
||||
bash "$sync_keys" "${sync_args[@]}"
|
||||
|
||||
# If sync-host-keys.sh changed .sops.yaml, secrets/, or vars/per-machine/,
|
||||
# those changes must be committed and pushed before the remote `git pull`
|
||||
# below picks them up -- the PVE node builds from whatever HEAD is checked
|
||||
# out there, not the local working tree. Uncommitted clan vars or sops
|
||||
# recipients mean the image builds fine but the host cannot decrypt its
|
||||
# secrets on first boot. Block until the operator confirms they've pushed.
|
||||
if [[ "$dry_run" -eq 0 ]]; then
|
||||
_dirty="$(git -C "$repo_root" status --porcelain -- .sops.yaml secrets/ vars/per-machine/ 2>/dev/null || true)"
|
||||
if [[ -n "$_dirty" ]]; then
|
||||
echo
|
||||
echo "==> COMMIT + PUSH REQUIRED before the remote build can succeed:"
|
||||
echo " Uncommitted changes in .sops.yaml, secrets/, or vars/per-machine/."
|
||||
echo " The PVE node builds from the git-tracked flake, so these changes"
|
||||
echo " must be committed and pushed first -- otherwise the image build will"
|
||||
echo " succeed but the host cannot decrypt its secrets on first boot."
|
||||
echo
|
||||
git -C "$repo_root" status --short -- .sops.yaml secrets/ vars/per-machine/ || true
|
||||
echo
|
||||
read -rp " Commit and push those changes, then press Enter to continue (Ctrl-C to abort): "
|
||||
fi
|
||||
unset _dirty
|
||||
fi
|
||||
|
||||
# --- VMID: pick one, and refuse to touch anything that already exists ---
|
||||
echo
|
||||
if [[ -z "$vmid" ]]; then
|
||||
@@ -613,21 +659,35 @@ ensure_remote_repo() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- sync locally-managed host-keys/ to the node ---------------------------
|
||||
# Gitignored (see .gitignore), so `git pull` above never carries it -- both
|
||||
# build paths need it present as NIXOS_HOST_KEYS_DIR / --pre-format-files
|
||||
# input on the node itself now that the build runs there. scp (not rsync,
|
||||
# not already a dependency anywhere else in this repo) mirrors how this
|
||||
# script already transfers the --image case below.
|
||||
# --- sync host key to the node ---------------------------------------------
|
||||
# SSH host keys are stored as clan vars (vars/per-machine/<target>/openssh/).
|
||||
# Decrypt locally and scp just the two files for this target to the node's
|
||||
# host-keys/ directory, where the remote build script picks them up via
|
||||
# NIXOS_HOST_KEYS_DIR (LXC) or --pre-format-files (VM). A target with no
|
||||
# clan var is an error -- generate one first with sync-host-keys.sh.
|
||||
sync_remote_host_keys() {
|
||||
echo
|
||||
echo "==> Syncing host-keys/ to ${node}..."
|
||||
echo "==> Syncing host key for ${flake_target} to ${node}..."
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would copy ${repo_root}/host-keys/ to ${ssh_target}:${remote_repo_dir}/host-keys/"
|
||||
echo "[dry-run] would decrypt clan SSH key for ${flake_target} and copy to ${ssh_target}:${remote_repo_dir}/host-keys/"
|
||||
return
|
||||
fi
|
||||
if ! clan_ssh_key_exists "$flake_target" "$repo_root"; then
|
||||
echo "ERROR: no clan SSH key found for ${flake_target}" >&2
|
||||
echo " (expected: ${repo_root}/vars/per-machine/${flake_target}/openssh/ssh_host_ed25519_key/secret)" >&2
|
||||
echo " Generate one first: bash scripts/secrets/sync-host-keys.sh ${flake_target}" >&2
|
||||
exit 1
|
||||
fi
|
||||
local tmpdir
|
||||
tmpdir="$(mktemp -d)"
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -rf '${tmpdir}'" RETURN
|
||||
echo " Decrypting clan SSH key for ${flake_target}..."
|
||||
clan_decrypt_ssh_key "$flake_target" "$repo_root" "$tmpdir"
|
||||
ssh "$ssh_target" "mkdir -p '${remote_repo_dir}/host-keys'"
|
||||
scp -pr "${repo_root}/host-keys/." "${ssh_target}:${remote_repo_dir}/host-keys/"
|
||||
scp -p "${tmpdir}/${flake_target}_ssh_host_ed25519_key" \
|
||||
"${tmpdir}/${flake_target}_ssh_host_ed25519_key.pub" \
|
||||
"${ssh_target}:${remote_repo_dir}/host-keys/"
|
||||
}
|
||||
|
||||
# --- build (or reuse an image already on the node) ------------------------
|
||||
@@ -642,10 +702,14 @@ if [[ -n "$image" ]]; then
|
||||
elif [[ "$force_rebuild" -eq 1 ]]; then
|
||||
echo "--force-rebuild: skipping the existing-image check on ${node}."
|
||||
else
|
||||
echo "==> Checking whether ${node} already has ${remote_path}..."
|
||||
# VMs: check for the raw image in the remote repo dir (where disko writes it).
|
||||
# LXC: check for the tarball in iso_storage (where the LXC build stages it).
|
||||
_check_path="$remote_path"
|
||||
[[ "$type" == "vm" ]] && _check_path="$vm_built_raw"
|
||||
echo "==> Checking whether ${node} already has ${_check_path}..."
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would check: ssh ${ssh_target} -- test -f ${remote_path}"
|
||||
elif ssh "$ssh_target" "test -f '${remote_path}'" 2>/dev/null; then
|
||||
echo "[dry-run] would check: ssh ${ssh_target} -- test -f ${_check_path}"
|
||||
elif ssh "$ssh_target" "test -f '${_check_path}'" 2>/dev/null; then
|
||||
echo "Found it -- reusing, skipping build (use --force-rebuild to override)."
|
||||
image_already_remote=1
|
||||
else
|
||||
@@ -696,6 +760,12 @@ cd "$repo_dir"
|
||||
# right after a successful install.
|
||||
. scripts/lib/nix-bootstrap.sh
|
||||
ensure_nix_profile
|
||||
if [[ ! -f "host-keys/${target}_ssh_host_ed25519_key" ]]; then
|
||||
echo "ERROR: host-keys/${target}_ssh_host_ed25519_key not found in ${repo_dir}." >&2
|
||||
echo "Generate the key locally (scripts/secrets/sync-host-keys.sh ${target})" >&2
|
||||
echo "and ensure it was synced here before starting the build." >&2
|
||||
exit 1
|
||||
fi
|
||||
NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build --impure \
|
||||
--no-use-registries --no-accept-flake-config "${NIX_OPTS[@]}" \
|
||||
".#nixosConfigurations.${target}.config.system.build.tarball" \
|
||||
@@ -714,46 +784,60 @@ REMOTE_SCRIPT
|
||||
fi
|
||||
else
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would build on ${node}: nix build --no-use-registries --no-accept-flake-config${nix_opts_display} \\"
|
||||
echo "[dry-run] would build on ${node}: NIXOS_HOST_KEYS_DIR=\$(pwd)/host-keys nix build --impure --no-use-registries --no-accept-flake-config${nix_opts_display} \\"
|
||||
echo "[dry-run] .#nixosConfigurations.${flake_target}.config.system.build.diskoImagesScript"
|
||||
echo "[dry-run] would run: ${sudo_display}./result-${flake_target} \\"
|
||||
echo "[dry-run] --pre-format-files host-keys/${flake_target}_ssh_host_ed25519_key /etc/ssh/ssh_host_ed25519_key \\"
|
||||
echo "[dry-run] --pre-format-files host-keys/${flake_target}_ssh_host_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub \\"
|
||||
echo "[dry-run] --build-memory 2048"
|
||||
echo "[dry-run] would stage the result at ${remote_path}"
|
||||
echo "[dry-run] would run: ${sudo_display}./result-${flake_target} --build-memory 2048"
|
||||
echo "[dry-run] image will be at ${vm_built_raw} (imported from there; no mv to /var/lib/vz/import/)"
|
||||
local_image="<built-image>.raw"
|
||||
else
|
||||
echo "==> Building Disko image for ${flake_target} on ${node}..."
|
||||
# See the LXC branch above for why this is one %q-quoted command
|
||||
# string rather than separate ssh argv elements.
|
||||
printf -v remote_cmd 'bash -s -- %q %q %q %q %q %q' \
|
||||
"$remote_repo_dir" "$flake_target" "$remote_dir" "$remote_filename" "$NIX_EXTRA_OPTS" "$sudo_prefix"
|
||||
# $7 = image_name (hostname, the diskoImagesScript's own output filename).
|
||||
#
|
||||
# NIXOS_HOST_KEYS_DIR + --impure: modules/platforms/proxmox.nix reads
|
||||
# this env var at eval time (like lxc.nix) to embed the clan SSH host
|
||||
# key in environment.etc. nixos-install's own activation then places the
|
||||
# key on the target disk, so sshd-keygen finds it already present and
|
||||
# skips generation. --pre-format-files put the key on the QEMU builder
|
||||
# VM's rootfs (not the target disk), so sshd-keygen regenerated a fresh
|
||||
# key -- one not registered in .sops.yaml -- and sops could never decrypt.
|
||||
printf -v remote_cmd 'bash -s -- %q %q %q %q %q %q %q' \
|
||||
"$remote_repo_dir" "$flake_target" "$remote_dir" "$remote_filename" "$NIX_EXTRA_OPTS" "$sudo_prefix" "$host"
|
||||
ssh "$ssh_target" "$remote_cmd" <<'REMOTE_SCRIPT'
|
||||
set -euo pipefail
|
||||
repo_dir="$1"; target="$2"; dest_dir="$3"; dest_name="$4"; nix_extra_opts_str="$5"; sudo_pfx="$6"
|
||||
repo_dir="$1"; target="$2"; dest_dir="$3"; dest_name="$4"; nix_extra_opts_str="$5"; sudo_pfx="$6"; image_name="$7"
|
||||
declare -a NIX_OPTS=()
|
||||
[[ -n "$nix_extra_opts_str" ]] && eval "NIX_OPTS=(${nix_extra_opts_str})"
|
||||
cd "$repo_dir"
|
||||
. scripts/lib/nix-bootstrap.sh
|
||||
ensure_nix_profile
|
||||
nix build --no-use-registries --no-accept-flake-config "${NIX_OPTS[@]}" \
|
||||
".#nixosConfigurations.${target}.config.system.build.diskoImagesScript" \
|
||||
--out-link "result-${target}"
|
||||
$sudo_pfx "./result-${target}" \
|
||||
--pre-format-files "host-keys/${target}_ssh_host_ed25519_key" /etc/ssh/ssh_host_ed25519_key \
|
||||
--pre-format-files "host-keys/${target}_ssh_host_ed25519_key.pub" /etc/ssh/ssh_host_ed25519_key.pub \
|
||||
--build-memory 2048
|
||||
built="$(find . -maxdepth 1 -name '*.raw' -newer "result-${target}" | head -1)"
|
||||
if [[ -z "$built" ]]; then
|
||||
echo "ERROR: no .raw image found in ${repo_dir} after build." >&2
|
||||
if [[ ! -f "host-keys/${target}_ssh_host_ed25519_key" ]]; then
|
||||
echo "ERROR: host-keys/${target}_ssh_host_ed25519_key not found in ${repo_dir}." >&2
|
||||
echo "Generate the key locally (scripts/secrets/sync-host-keys.sh ${target})" >&2
|
||||
echo "and ensure it was synced here before starting the build." >&2
|
||||
exit 1
|
||||
fi
|
||||
$sudo_pfx mkdir -p "$dest_dir"
|
||||
$sudo_pfx mv "$built" "${dest_dir}/${dest_name}"
|
||||
echo "Built and staged: ${dest_dir}/${dest_name}"
|
||||
# Build diskoImagesScript with NIXOS_HOST_KEYS_DIR so proxmox.nix embeds the
|
||||
# clan SSH key in environment.etc (same as lxc.nix). This causes nixos-install
|
||||
# to place the key on the target disk, so sshd-keygen finds it and skips
|
||||
# generation -- the disk image boots with the registered key, sops decrypts.
|
||||
NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build --impure \
|
||||
--no-use-registries --no-accept-flake-config "${NIX_OPTS[@]}" \
|
||||
".#nixosConfigurations.${target}.config.system.build.diskoImagesScript" \
|
||||
--out-link "result-${target}"
|
||||
# Remove any stale .raw from a previous failed build so the post-build check
|
||||
# below is unambiguous (diskoImagesScript writes to CWD as ${image_name}.raw).
|
||||
$sudo_pfx rm -f "${image_name}.raw" 2>/dev/null || true
|
||||
$sudo_pfx "./result-${target}" --build-memory 2048
|
||||
if [[ ! -f "${image_name}.raw" ]]; then
|
||||
echo "ERROR: ${image_name}.raw not found in ${repo_dir} after build -- disko/QEMU may have failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Built image: ${repo_dir}/${image_name}.raw"
|
||||
REMOTE_SCRIPT
|
||||
local_image="$remote_path"
|
||||
echo "Built on ${node}: ${remote_path}"
|
||||
local_image="$vm_built_raw"
|
||||
echo "Built on ${node}: ${vm_built_raw}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -783,17 +867,17 @@ if [[ "$type" == "lxc" ]]; then
|
||||
local_swap="${swap:-$memory}"
|
||||
# --unprivileged: read back from modules/platforms/lxc.nix's own
|
||||
# proxmoxLXC.privileged (via flake_target_lxc_privileged) rather than
|
||||
# hardcoded, since that's no longer the same for every lxc-* target --
|
||||
# lxc-docker sets it true so the container's NFS mounts work at all (the
|
||||
# kernel's NFS client can't mount from inside any unprivileged
|
||||
# container's user namespace, no matter what AppArmor allows -- see that
|
||||
# option's own comment). The NixOS config inside the image bakes in
|
||||
# cgroup/capability/mount expectations matching whichever value it was
|
||||
# built with, so this must stay in sync with it -- `pct create`'s own
|
||||
# CLI default for this flag is privileged (unlike the web UI, which
|
||||
# defaults its checkbox the other way), so leaving it unset would create
|
||||
# a privileged container running a NixOS config that assumes
|
||||
# unprivileged for every target except lxc-docker, a real mismatch.
|
||||
# hardcoded. lxc.nix derives this automatically: any lxc-* host whose
|
||||
# config.fileSystems has an NFS entry gets privileged=true, because the
|
||||
# kernel's NFS client (FS_USERNS_MOUNT not set) rejects NFS mounts from
|
||||
# inside any non-init user namespace -- exactly what an unprivileged
|
||||
# container's UID-mapped root lives in -- with EPERM at the VFS layer,
|
||||
# regardless of AppArmor (see lxc.nix's own comment). The NixOS config
|
||||
# bakes in cgroup/capability/mount expectations matching whichever value
|
||||
# it was built with, so this must stay in sync -- `pct create`'s CLI
|
||||
# default is privileged (unlike the web UI, which defaults the other
|
||||
# way), so leaving it unset would create a privileged container running
|
||||
# a NixOS config that assumes unprivileged, a real mismatch.
|
||||
privileged_eval="$(flake_target_lxc_privileged "$repo_root" "$flake_target")"
|
||||
unprivileged_flag=1
|
||||
[[ "$privileged_eval" == "true" ]] && unprivileged_flag=0
|
||||
@@ -831,14 +915,35 @@ else
|
||||
--net0 virtio,bridge=${bridge} --bios ovmf --machine q35 --scsihw virtio-scsi-pci \
|
||||
--efidisk0 ${storage}:1,efitype=4m,pre-enrolled-keys=0 --agent enabled=1"
|
||||
|
||||
# VMs built on the node: import from the repo dir (where disko/QEMU wrote it).
|
||||
# VMs from --image: import from remote_path (where scp uploaded it).
|
||||
_import_path="${remote_path}"
|
||||
[[ -z "$image" ]] && _import_path="${vm_built_raw}"
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] ssh ${ssh_target} -- ${sudo_display}qm importdisk ${vmid} ${remote_path} ${storage}"
|
||||
echo "[dry-run] ssh ${ssh_target} -- ${sudo_display}qm importdisk ${vmid} ${_import_path} ${storage}"
|
||||
echo "[dry-run] (would parse the resulting disk identifier from that output)"
|
||||
echo "[dry-run] ssh ${ssh_target} -- ${sudo_display}qm set ${vmid} --scsi0 ${storage}:<parsed-disk-id>"
|
||||
else
|
||||
importdisk_output="$(ssh "$ssh_target" "${sudo_prefix} qm importdisk ${vmid} ${remote_path} ${storage}")"
|
||||
if ! importdisk_output="$(ssh "$ssh_target" "${sudo_prefix} qm importdisk ${vmid} ${_import_path} ${storage}" 2>&1)"; then
|
||||
echo "ERROR: qm importdisk failed:" >&2
|
||||
echo "${importdisk_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$importdisk_output"
|
||||
disk_id="$(echo "$importdisk_output" | grep -oP "(?<=Successfully imported disk as ')[^']+" | sed 's/^unused[0-9]*://')"
|
||||
# PVE output format: "unusedN: successfully imported disk '<storage>:<vol>'"
|
||||
# (lowercase "successfully", no "as"; the primary regex targets this form; the
|
||||
# || true inside the substitution prevents set -e from aborting when grep finds
|
||||
# no match -- without it the script would silently exit before reaching the
|
||||
# fallback whenever the PVE format doesn't match).
|
||||
disk_id="$(echo "$importdisk_output" | grep -oP "successfully imported disk '\\K[^']+" || true)"
|
||||
if [[ -z "$disk_id" ]]; then
|
||||
# Fallback for other PVE output variants: read qm config directly.
|
||||
unused_line="$(ssh "$ssh_target" "${sudo_prefix} qm config ${vmid}" | grep '^unused[0-9]*:' | head -1 || true)"
|
||||
if [[ -n "$unused_line" ]]; then
|
||||
disk_id="${unused_line#*: }"
|
||||
echo "Note: disk ID resolved from qm config: ${disk_id}"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$disk_id" ]]; then
|
||||
echo "ERROR: couldn't parse the imported disk identifier from qm importdisk's output above." >&2
|
||||
echo "The VM shell (${vmid}) and imported disk both exist -- finish attaching it by hand:" >&2
|
||||
@@ -847,6 +952,12 @@ else
|
||||
exit 1
|
||||
fi
|
||||
remote "${sudo_prefix} qm set ${vmid} --scsi0 ${disk_id}"
|
||||
# The disk data is now in ZFS; remove the source raw file (only for images
|
||||
# we built on the node -- --image uploads are the operator's to manage).
|
||||
if [[ -z "$image" ]]; then
|
||||
ssh "$ssh_target" "${sudo_prefix} rm -f '${_import_path}'" 2>/dev/null || \
|
||||
echo "Warning: couldn't remove ${_import_path} from ${node} -- you can delete it manually" >&2
|
||||
fi
|
||||
fi
|
||||
remote "${sudo_prefix} qm set ${vmid} --boot order=scsi0"
|
||||
remote "${sudo_prefix} qm start ${vmid}"
|
||||
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# recover-hosts.sh — Fix sops/SSH-key/GitHub-token issues on deployed NixOS hosts
|
||||
# and trigger a Switch-nix rebuild on each.
|
||||
#
|
||||
# Run from the repo root on the workstation (nixos@nixos):
|
||||
# bash scripts/recover-hosts.sh [<hostname> ...]
|
||||
#
|
||||
# With no args it discovers and checks every known hostname.
|
||||
# With args it checks only those hostnames:
|
||||
# bash scripts/recover-hosts.sh tor-relay
|
||||
#
|
||||
# Fixes applied automatically (then prompts before rebuilding):
|
||||
# 1. SSH host key drift — live key no longer matches host-keys/<target>_ssh_host_ed25519_key
|
||||
# Fix: scp the registered key back and restore it (needs sudo once per host).
|
||||
# To push new keys proactively (before drift, e.g. right after
|
||||
# sync-host-keys.sh --regenerate-all-keys), use instead:
|
||||
# scripts/secrets/push-host-keys.sh --all
|
||||
# 2. Stale/invalid GitHub access token — the rendered nix-github-token.conf has
|
||||
# a token GitHub rejects (401), blocking any rebuild that fetches disko or
|
||||
# other public GitHub flake inputs.
|
||||
# Fix: empty the rendered file so nix makes unauthenticated requests instead.
|
||||
# Public repos (disko, nixpkgs, etc.) work fine without auth. sops-nix
|
||||
# re-renders the correct new token automatically after the first successful
|
||||
# rebuild.
|
||||
#
|
||||
# Both fixes need one interactive sudo session per host. The script opens a
|
||||
# single ssh -t per broken host so you enter the password once and all steps
|
||||
# run in sequence.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh 2>/dev/null || true
|
||||
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5)
|
||||
SSH_USER=nixos
|
||||
|
||||
# Known flake-target → ssh hostname map for all currently-defined hosts.
|
||||
# Add new hosts here as they are deployed.
|
||||
declare -A TARGET_HOST=(
|
||||
[lxc-docker]=docker
|
||||
[lxc-nix-cache]=nix-cache
|
||||
[lxc-pxe-boot]=pxe-boot
|
||||
[lxc-tor-relay]=tor-relay
|
||||
[lxc-minimal]=nix-minimal
|
||||
[proxmox-server]=server
|
||||
[baremetal-gui]=nixos
|
||||
)
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
info() { echo " [✓] $*"; }
|
||||
warn() { echo " [!] $*"; }
|
||||
step() { echo "==> $*"; }
|
||||
|
||||
ssh_host_age() {
|
||||
ssh-keyscan -t ed25519 "$1" 2>/dev/null \
|
||||
| nix shell nixpkgs#ssh-to-age --command ssh-to-age 2>/dev/null \
|
||||
| head -1 || true
|
||||
}
|
||||
|
||||
registered_age() {
|
||||
local keyfile="host-keys/${1}_ssh_host_ed25519_key.pub"
|
||||
[ -f "$keyfile" ] || return 0
|
||||
nix shell nixpkgs#ssh-to-age --command ssh-to-age < "$keyfile" 2>/dev/null \
|
||||
| head -1 || true
|
||||
}
|
||||
|
||||
github_token_valid() {
|
||||
local host=$1
|
||||
local raw token code
|
||||
raw=$(ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" \
|
||||
"cat /run/secrets/rendered/nix-github-token.conf 2>/dev/null || true")
|
||||
token=$(echo "$raw" | grep -oP '(?<=github\.com=)\S+' || true)
|
||||
if [ -z "$token" ]; then
|
||||
return 0 # no token = unauthenticated, works for public repos
|
||||
fi
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $token" \
|
||||
"https://api.github.com/repos/nix-community/disko" 2>/dev/null || echo 000)
|
||||
[ "$code" = "200" ]
|
||||
}
|
||||
|
||||
# ── discover hosts ────────────────────────────────────────────────────────────
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
HOSTNAMES=("$@")
|
||||
else
|
||||
HOSTNAMES=()
|
||||
seen=()
|
||||
for target in "${!TARGET_HOST[@]}"; do
|
||||
h="${TARGET_HOST[$target]}"
|
||||
# deduplicate (e.g. proxmox-server and lxc-server both map to "server")
|
||||
if [[ ! " ${seen[*]:-} " =~ " $h " ]]; then
|
||||
seen+=("$h")
|
||||
if ssh "${SSH_OPTS[@]}" "$SSH_USER@$h" "true" 2>/dev/null; then
|
||||
HOSTNAMES+=("$h")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ${#HOSTNAMES[@]} -eq 0 ]; then
|
||||
echo "No reachable hosts found. Pass hostnames explicitly or check SSH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Hosts to check: ${HOSTNAMES[*]}"
|
||||
echo ""
|
||||
|
||||
# ── check phase ───────────────────────────────────────────────────────────────
|
||||
|
||||
NEEDS_FIX=()
|
||||
|
||||
for host in "${HOSTNAMES[@]}"; do
|
||||
step "$host"
|
||||
|
||||
if ! ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" "true" 2>/dev/null; then
|
||||
warn "SSH unreachable — clearing stale known_hosts entry"
|
||||
ssh-keygen -R "$host" 2>/dev/null || true
|
||||
continue
|
||||
fi
|
||||
|
||||
flake_target=$(ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" \
|
||||
"cat /etc/flake-target 2>/dev/null || true")
|
||||
echo " flake-target: ${flake_target:-unknown}"
|
||||
|
||||
host_broken=false
|
||||
|
||||
# SSH host key
|
||||
if [ -n "$flake_target" ] && [ -f "host-keys/${flake_target}_ssh_host_ed25519_key.pub" ]; then
|
||||
live=$(ssh_host_age "$host")
|
||||
want=$(registered_age "$flake_target")
|
||||
if [ "$live" = "$want" ]; then
|
||||
info "SSH host key OK"
|
||||
else
|
||||
warn "SSH host key MISMATCH (live ≠ host-keys/) -- use push-host-keys.sh proactively next time"
|
||||
echo " live: $live"
|
||||
echo " registered: $want"
|
||||
host_broken=true
|
||||
fi
|
||||
else
|
||||
echo " [~] No host-keys/ entry for ${flake_target:-unknown} — skipping key check"
|
||||
fi
|
||||
|
||||
# GitHub token
|
||||
if github_token_valid "$host"; then
|
||||
info "GitHub token OK"
|
||||
else
|
||||
warn "GitHub token invalid (rebuild will fail with 401)"
|
||||
host_broken=true
|
||||
fi
|
||||
|
||||
# sops-nix result
|
||||
sops_result=$(ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" \
|
||||
"systemctl show sops-nix --property=Result --value 2>/dev/null || echo unknown")
|
||||
if [ "$sops_result" = "success" ]; then
|
||||
info "sops-nix: success"
|
||||
else
|
||||
warn "sops-nix: $sops_result"
|
||||
fi
|
||||
|
||||
$host_broken && NEEDS_FIX+=("$host")
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ── fix phase ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ ${#NEEDS_FIX[@]} -eq 0 ]; then
|
||||
echo "All hosts healthy — nothing to fix."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Hosts needing fixes: ${NEEDS_FIX[*]}"
|
||||
echo ""
|
||||
echo "Each fix requires one sudo session per host. You will be prompted for"
|
||||
echo "the nixos sudo password once per host; all steps run in that session."
|
||||
echo ""
|
||||
read -r -p "Proceed with fixes + Switch-nix on each broken host? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
echo ""
|
||||
|
||||
for host in "${NEEDS_FIX[@]}"; do
|
||||
step "Fixing $host"
|
||||
flake_target=$(ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" \
|
||||
"cat /etc/flake-target 2>/dev/null || true")
|
||||
|
||||
fix_script=""
|
||||
|
||||
# Fix 1: restore SSH host key
|
||||
live=$(ssh_host_age "$host")
|
||||
want=$(registered_age "${flake_target:-}")
|
||||
if [ -n "$want" ] && [ "$live" != "$want" ]; then
|
||||
echo " Uploading registered SSH host key (private + public)..."
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
"host-keys/${flake_target}_ssh_host_ed25519_key" \
|
||||
"$SSH_USER@$host:/tmp/recover_ed25519_key"
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
"host-keys/${flake_target}_ssh_host_ed25519_key.pub" \
|
||||
"$SSH_USER@$host:/tmp/recover_ed25519_key.pub"
|
||||
fix_script+='
|
||||
echo "[fix] Restoring SSH host key..."
|
||||
install -m 0600 /tmp/recover_ed25519_key /etc/ssh/ssh_host_ed25519_key
|
||||
install -m 0644 /tmp/recover_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub
|
||||
rm -f /tmp/recover_ed25519_key /tmp/recover_ed25519_key.pub
|
||||
echo " Done."
|
||||
'
|
||||
ssh-keygen -R "$host" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Fix 2: clear invalid GitHub token
|
||||
if ! github_token_valid "$host"; then
|
||||
fix_script+='
|
||||
echo "[fix] Clearing stale GitHub token (nix will use unauthenticated access)..."
|
||||
echo "" > /run/secrets/rendered/nix-github-token.conf
|
||||
systemctl restart nix-daemon 2>/dev/null || true
|
||||
echo " Done."
|
||||
'
|
||||
fi
|
||||
|
||||
# Fix 3: rebuild
|
||||
fix_script+='
|
||||
echo "[fix] Running nixos-rebuild switch..."
|
||||
nixos-rebuild switch \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake "git+https://gitea.lan.ddnsgeek.com/beatzaplenty/nixos.git#$(cat /etc/flake-target)"
|
||||
echo "[fix] Rebuild complete."
|
||||
'
|
||||
|
||||
echo " Opening SSH session (enter sudo password when prompted)..."
|
||||
if ssh -t -o StrictHostKeyChecking=no "$SSH_USER@$host" \
|
||||
"sudo bash -s" <<< "$fix_script"; then
|
||||
echo ""
|
||||
info "$host fixed and rebuilt"
|
||||
else
|
||||
rc=$?
|
||||
echo ""
|
||||
warn "$host: rebuild exited with code $rc (may still have succeeded — check sops-nix below)"
|
||||
fi
|
||||
|
||||
# Verify: re-check sops-nix result post-rebuild
|
||||
sops_result_after=$(ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" \
|
||||
"systemctl show sops-nix --property=Result --value 2>/dev/null || echo unknown" 2>/dev/null || echo "ssh-failed")
|
||||
if [ "$sops_result_after" = "success" ]; then
|
||||
info "$host sops-nix: success post-rebuild"
|
||||
else
|
||||
warn "$host sops-nix: $sops_result_after post-rebuild (may need another pass)"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "Recovery complete."
|
||||
@@ -41,8 +41,9 @@ mkdir -p "$keydir"
|
||||
keyfile="${keydir}/${hostname}_ssh_host_ed25519_key"
|
||||
|
||||
if [[ -f "$keyfile" ]]; then
|
||||
echo "ERROR: $keyfile already exists. Remove it first if you want to regenerate." >&2
|
||||
exit 1
|
||||
echo "Key already exists: ${keyfile}"
|
||||
echo "Reusing the existing key. Remove it first if you want to regenerate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
nix_extra_opts
|
||||
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pushes newly-generated SSH host keys from host-keys/ to already-running
|
||||
# NixOS hosts, so they can decrypt sops secrets after a nixos-rebuild
|
||||
# following scripts/secrets/sync-host-keys.sh --regenerate-all-keys.
|
||||
#
|
||||
# Before pushing any key, verifies that .sops.yaml and secrets/*.yaml are
|
||||
# committed and pushed to the remote -- hosts rebuild from the remote Gitea
|
||||
# flake, so recipient changes must land there before any rebuild, not just
|
||||
# before the key push.
|
||||
#
|
||||
# push-host-keys.sh --all [--dry-run] [--skip-git-check]
|
||||
# push-host-keys.sh <target> [--dry-run] [--skip-git-check]
|
||||
#
|
||||
# --all Push to every reachable managed host. Default when no
|
||||
# target is given.
|
||||
# <target> Push to one flake target only (e.g. lxc-server).
|
||||
# --dry-run Print what would be done; write nothing.
|
||||
# --skip-git-check Skip the commit/push check. Use only when the remote
|
||||
# already has the current .sops.yaml/secrets/*.yaml.
|
||||
#
|
||||
# SSH: connects as SSH_USER@<hostname> (default: nixos, the user with the
|
||||
# admin authorized key), then installs files via sudo -S (reads the sudo
|
||||
# password from stdin). The password is prompted once at startup and reused
|
||||
# for every host -- no PTY or terminal required on the remote side.
|
||||
# Hosts are reached at their bare hostname (relies on LAN DNS/mDNS).
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
keydir="${repo_root}/host-keys"
|
||||
|
||||
# shellcheck source=../env.sh
|
||||
source "${repo_root}/scripts/env.sh"
|
||||
# shellcheck source=../lib/nix-eval.sh
|
||||
source "${repo_root}/scripts/lib/nix-eval.sh"
|
||||
|
||||
: "${SSH_USER:=nixos}"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5)
|
||||
|
||||
dry_run=0
|
||||
skip_git_check=0
|
||||
sudo_password=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [--all | <target>] [--dry-run] [--skip-git-check]
|
||||
|
||||
--all Push to every reachable managed host. Default when no
|
||||
target is given.
|
||||
<target> Push to one flake target only (e.g. lxc-server).
|
||||
--dry-run Print what would be done; write nothing.
|
||||
--skip-git-check Skip the check that .sops.yaml/secrets/*.yaml are
|
||||
committed and pushed to the remote repo.
|
||||
|
||||
Environment:
|
||||
SSH_USER SSH username (default: nixos).
|
||||
SUDO_PASS Sudo password (skips the interactive prompt; useful
|
||||
when calling from another script).
|
||||
EOF
|
||||
}
|
||||
|
||||
# Prompt for the sudo password once; store it for all _do_push calls.
|
||||
# Accepts SUDO_PASS from the environment to allow non-interactive callers.
|
||||
prompt_sudo_password() {
|
||||
[[ "$dry_run" -eq 1 ]] && return
|
||||
if [[ -n "${SUDO_PASS:-}" ]]; then
|
||||
sudo_password="$SUDO_PASS"
|
||||
return
|
||||
fi
|
||||
# read exits non-zero when stdin is not a terminal (e.g. CI, background
|
||||
# agents). Catch that and give a clear message rather than a silent exit.
|
||||
if ! read -r -s -p "sudo password for ${SSH_USER} on remote hosts: " sudo_password; then
|
||||
echo >&2
|
||||
echo "ERROR: stdin is not a terminal -- cannot prompt for sudo password." >&2
|
||||
echo " Set SUDO_PASS=<password> in the environment and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo >&2
|
||||
}
|
||||
|
||||
locally_managed_hosts() {
|
||||
for f in "${keydir}"/*_ssh_host_ed25519_key.pub; do
|
||||
[[ -e "$f" ]] || continue
|
||||
basename "$f" _ssh_host_ed25519_key.pub
|
||||
done
|
||||
}
|
||||
|
||||
# --- git state check/fix --------------------------------------------------
|
||||
# Hosts rebuild from the remote Gitea flake:
|
||||
# nixos-rebuild switch --flake "git+https://<gitea>/nixos.git#<target>"
|
||||
# so .sops.yaml (updated recipients) and secrets/*.yaml (re-encrypted DEKs)
|
||||
# must be committed and pushed before any rebuild can succeed. This check
|
||||
# catches the common case where --regenerate-all-keys was just run but the
|
||||
# resulting diff hasn't been committed/pushed yet.
|
||||
ensure_remote_current() {
|
||||
[[ "$skip_git_check" -eq 1 ]] && return
|
||||
|
||||
cd "$repo_root"
|
||||
|
||||
local dirty_unstaged dirty_staged
|
||||
dirty_unstaged="$(git diff --name-only -- .sops.yaml secrets/ 2>/dev/null || true)"
|
||||
dirty_staged="$(git diff --cached --name-only -- .sops.yaml secrets/ 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$dirty_unstaged" || -n "$dirty_staged" ]]; then
|
||||
echo "Uncommitted changes in sops-managed files:"
|
||||
[[ -n "$dirty_unstaged" ]] && sed 's/^/ (unstaged) /' <<<"$dirty_unstaged"
|
||||
[[ -n "$dirty_staged" ]] && sed 's/^/ (staged) /' <<<"$dirty_staged"
|
||||
echo
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would prompt to commit .sops.yaml/secrets/ before continuing."
|
||||
else
|
||||
read -rp "Commit .sops.yaml + secrets/ now? [y/N]: " ans
|
||||
if [[ "$ans" =~ ^[Yy]$ ]]; then
|
||||
git add -- .sops.yaml secrets/
|
||||
git commit -m "secrets: update recipients and re-encrypt for host key changes"
|
||||
echo "Committed."
|
||||
else
|
||||
echo "Continuing with uncommitted changes -- the remote won't have the"
|
||||
echo "updated recipients until you commit and push."
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
# Check if we're ahead of the remote tracking branch
|
||||
local ahead
|
||||
ahead="$(git rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo "")"
|
||||
if [[ -z "$ahead" ]]; then
|
||||
echo "NOTE: no remote tracking branch found -- skipping push check."
|
||||
echo " Ensure the remote has the current .sops.yaml/secrets/ before"
|
||||
echo " triggering nixos-rebuild on any host."
|
||||
echo
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$ahead" -gt 0 ]]; then
|
||||
echo "Local branch is ${ahead} commit(s) ahead of remote."
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would prompt to push before continuing."
|
||||
else
|
||||
read -rp "Push to remote now? [y/N]: " ans
|
||||
if [[ "$ans" =~ ^[Yy]$ ]]; then
|
||||
git push
|
||||
echo "Pushed."
|
||||
else
|
||||
echo "Continuing without pushing -- remember to push before running"
|
||||
echo "nixos-rebuild on any of these hosts."
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
# --- key installation (shared) -------------------------------------------
|
||||
_do_push() {
|
||||
local hostname="$1" target="$2"
|
||||
local keyfile="${keydir}/${target}_ssh_host_ed25519_key"
|
||||
local pubfile="${keyfile}.pub"
|
||||
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo " [dry-run] would scp host-keys/${target}_ssh_host_ed25519_key{,.pub} to /tmp/"
|
||||
echo " [dry-run] would: sudo -S install -m 0600/0644 to /etc/ssh/ and rm /tmp copies"
|
||||
return
|
||||
fi
|
||||
|
||||
# Upload to /tmp (writable as nixos, no privilege needed)
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
"$keyfile" "${SSH_USER}@${hostname}:/tmp/push_ed25519_key"
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
"$pubfile" "${SSH_USER}@${hostname}:/tmp/push_ed25519_key.pub"
|
||||
|
||||
# Install via sudo -S: the password is piped via herestring so no PTY is
|
||||
# needed on either side. -p '' suppresses sudo's own prompt string.
|
||||
ssh -o StrictHostKeyChecking=no "${SSH_USER}@${hostname}" \
|
||||
"sudo -S -p '' bash -c '
|
||||
install -m 0600 /tmp/push_ed25519_key /etc/ssh/ssh_host_ed25519_key
|
||||
install -m 0644 /tmp/push_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub
|
||||
rm -f /tmp/push_ed25519_key /tmp/push_ed25519_key.pub
|
||||
echo \" [ok] host key installed\"
|
||||
'" <<< "$sudo_password"
|
||||
|
||||
# Drop the stale known_hosts entry for this host (public key just changed)
|
||||
ssh-keygen -R "$hostname" 2>/dev/null || true
|
||||
|
||||
echo " Done. Run nixos-rebuild switch on ${hostname} to activate."
|
||||
}
|
||||
|
||||
# --- single named target --------------------------------------------------
|
||||
push_target() {
|
||||
local target="$1"
|
||||
local keyfile="${keydir}/${target}_ssh_host_ed25519_key"
|
||||
|
||||
if [[ ! -f "$keyfile" ]]; then
|
||||
echo "ERROR: host-keys/${target}_ssh_host_ed25519_key not found." >&2
|
||||
echo " This target may not be locally managed (e.g. &${target} was" >&2
|
||||
echo " registered from the host's real SSH key, not generated here)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local hostname
|
||||
hostname="$(flake_target_hostname "$repo_root" "$target")"
|
||||
if [[ -z "$hostname" ]]; then
|
||||
echo "ERROR: cannot resolve hostname for '${target}' from the flake." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> ${target} (→ ${hostname})"
|
||||
|
||||
if ! ssh "${SSH_OPTS[@]}" "${SSH_USER}@${hostname}" true 2>/dev/null; then
|
||||
echo " SKIP: ${SSH_USER}@${hostname} unreachable."
|
||||
return
|
||||
fi
|
||||
|
||||
# Sanity-check that /etc/flake-target on the host agrees
|
||||
local live_target
|
||||
live_target="$(ssh "${SSH_OPTS[@]}" "${SSH_USER}@${hostname}" \
|
||||
"cat /etc/flake-target 2>/dev/null || true")"
|
||||
if [[ -n "$live_target" && "$live_target" != "$target" ]]; then
|
||||
echo " WARN: host reports /etc/flake-target='${live_target}', not '${target}'."
|
||||
echo " Pushing the key you specified (${target}) anyway."
|
||||
fi
|
||||
|
||||
_do_push "$hostname" "$target"
|
||||
}
|
||||
|
||||
# --- all managed hosts ----------------------------------------------------
|
||||
# For each unique hostname derived from managed targets, SSHes in and reads
|
||||
# /etc/flake-target to determine which key to push -- handles the case where
|
||||
# multiple targets share a hostname (e.g. lxc-server and proxmox-server both
|
||||
# resolve to "server"; only one is actually running).
|
||||
push_all() {
|
||||
mapfile -t managed < <(locally_managed_hosts)
|
||||
if [[ "${#managed[@]}" -eq 0 ]]; then
|
||||
echo "No managed keys in host-keys/ -- nothing to push."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Pushing to all reachable managed hosts..."
|
||||
echo
|
||||
|
||||
declare -A seen_hostnames=()
|
||||
local t hostname
|
||||
for t in "${managed[@]}"; do
|
||||
hostname="$(flake_target_hostname "$repo_root" "$t" 2>/dev/null || true)"
|
||||
[[ -z "$hostname" ]] && continue
|
||||
[[ -n "${seen_hostnames[$hostname]+x}" ]] && continue
|
||||
seen_hostnames["$hostname"]=1
|
||||
|
||||
echo "==> checking ${hostname}"
|
||||
|
||||
if ! ssh "${SSH_OPTS[@]}" "${SSH_USER}@${hostname}" true 2>/dev/null; then
|
||||
echo " SKIP: ${SSH_USER}@${hostname} unreachable."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Ask the host which flake target it actually is
|
||||
local live_target
|
||||
live_target="$(ssh "${SSH_OPTS[@]}" "${SSH_USER}@${hostname}" \
|
||||
"cat /etc/flake-target 2>/dev/null || true")"
|
||||
|
||||
if [[ -z "$live_target" ]]; then
|
||||
echo " SKIP: no /etc/flake-target on host -- can't determine which key to push."
|
||||
continue
|
||||
fi
|
||||
|
||||
local live_keyfile="${keydir}/${live_target}_ssh_host_ed25519_key"
|
||||
if [[ ! -f "$live_keyfile" ]]; then
|
||||
echo " SKIP: host is '${live_target}' but no host-keys/${live_target}_... (hand-registered key, not managed here)."
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " target: ${live_target}"
|
||||
_do_push "$hostname" "$live_target"
|
||||
done
|
||||
}
|
||||
|
||||
# --- main -----------------------------------------------------------------
|
||||
mode="all"
|
||||
target_arg=""
|
||||
extra_args=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--skip-git-check) skip_git_check=1 ;;
|
||||
--all) mode="all" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--*) echo "Unknown option: $arg" >&2; usage >&2; exit 1 ;;
|
||||
*) extra_args+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${#extra_args[@]}" -gt 1 ]]; then
|
||||
echo "ERROR: specify at most one target (or --all)." >&2
|
||||
usage >&2; exit 1
|
||||
elif [[ "${#extra_args[@]}" -eq 1 ]]; then
|
||||
mode="single"
|
||||
target_arg="${extra_args[0]}"
|
||||
fi
|
||||
|
||||
[[ "$dry_run" -eq 1 ]] && { echo "[dry-run] no changes will be made"; echo; }
|
||||
|
||||
nix_extra_opts
|
||||
ensure_remote_current
|
||||
prompt_sudo_password
|
||||
|
||||
if [[ "$mode" == "single" ]]; then
|
||||
push_target "$target_arg"
|
||||
else
|
||||
push_all
|
||||
fi
|
||||
|
||||
echo
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] Nothing was changed. Re-run without --dry-run to apply."
|
||||
else
|
||||
echo "Key push complete. For each updated host, run nixos-rebuild switch to"
|
||||
echo "apply the config and let sops-nix decrypt secrets with the new key."
|
||||
fi
|
||||
@@ -11,17 +11,16 @@
|
||||
# sync-host-keys.sh --regenerate-all-keys Remove and freshly regenerate
|
||||
# every locally-managed key.
|
||||
#
|
||||
# "Generate/register" is idempotent and additive only: an existing
|
||||
# host-keys/ file is never touched, and .sops.yaml only ever gains an
|
||||
# anchor/alias it doesn't already have -- safe to re-run any time, e.g.
|
||||
# right after adding a new host to flake.nix.
|
||||
# "Generate/register" is idempotent and additive only: an existing clan
|
||||
# var is never overwritten, and .sops.yaml only ever gains an anchor/alias
|
||||
# it doesn't already have -- safe to re-run any time, e.g. right after
|
||||
# adding a new host to flake.nix.
|
||||
#
|
||||
# --remove and --regenerate-all-keys only ever operate on anchors that have
|
||||
# a corresponding host-keys/<name>_ssh_host_ed25519_key file. Anchors
|
||||
# without one (&admin, and any anchor for an already-deployed host whose
|
||||
# real /etc/ssh key was registered by hand, e.g. &docker/&server/&nix-cache
|
||||
# today) are never listed, removed, or regenerated -- this tooling only
|
||||
# ever touches keys it itself manages.
|
||||
# --remove and --regenerate-all-keys only ever operate on anchors that
|
||||
# have a corresponding clan var (vars/per-machine/<name>/openssh/) or
|
||||
# host-keys/ file. Anchors without either (&admin) are never listed,
|
||||
# removed, or regenerated -- this tooling only ever touches keys it itself
|
||||
# manages.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
@@ -39,6 +38,8 @@ source "${repo_root}/scripts/lib/ssh-host-keys.sh"
|
||||
source "${repo_root}/scripts/lib/sops-age.sh"
|
||||
# shellcheck source=../lib/confirm.sh
|
||||
source "${repo_root}/scripts/lib/confirm.sh"
|
||||
# shellcheck source=../lib/clan-vars.sh
|
||||
source "${repo_root}/scripts/lib/clan-vars.sh"
|
||||
|
||||
mkdir -p "$keydir"
|
||||
|
||||
@@ -54,13 +55,14 @@ Usage: $0 --all [--dry-run]
|
||||
<flake-target> Same, for just one target (e.g. lxc-server).
|
||||
Reports if it already has one.
|
||||
--remove Interactively pick one locally-managed key to
|
||||
remove from .sops.yaml and host-keys/.
|
||||
remove from .sops.yaml and vars/per-machine/
|
||||
(or host-keys/ for legacy keys).
|
||||
--regenerate-all-keys Remove every locally-managed key and generate
|
||||
fresh replacements for every current flake
|
||||
target. Destructive -- requires typed
|
||||
fresh clan-var replacements for every current
|
||||
flake target. Destructive -- requires typed
|
||||
confirmation.
|
||||
--dry-run Combine with any of the above: print what would
|
||||
change (host-keys/ files, .sops.yaml anchors and
|
||||
change (clan vars, .sops.yaml anchors and
|
||||
key_groups, which secrets/*.yaml would be
|
||||
re-encrypted) without touching anything. No keys
|
||||
generated, no files written, no sops calls,
|
||||
@@ -83,6 +85,10 @@ ensure_admin_decrypt_key() {
|
||||
fi
|
||||
|
||||
local key_file="$DEFAULT_SOPS_AGE_KEY_FILE"
|
||||
# Expand a leading ~ that survived variable substitution without tilde
|
||||
# expansion (happens when SOPS_AGE_KEY_FILE or XDG_CONFIG_HOME is set with
|
||||
# a literal ~ in the caller's environment).
|
||||
key_file="${key_file/#~\//$HOME/}"
|
||||
|
||||
if [[ -s "$key_file" ]]; then
|
||||
echo "Found existing sops age key at ${key_file}."
|
||||
@@ -91,36 +97,23 @@ ensure_admin_decrypt_key() {
|
||||
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] No sops age decryption key found (checked \$SOPS_AGE_KEY, \$SOPS_AGE_KEY_FILE, ${key_file})."
|
||||
echo "[dry-run] Would generate a new one here -- continuing the dry run without one; any"
|
||||
echo "[dry-run] 'would re-encrypt' output below couldn't actually run for real yet."
|
||||
echo "[dry-run] Continuing dry run without one -- any 'would re-encrypt' output below"
|
||||
echo "[dry-run] couldn't actually run for real until a key is present."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "No sops age decryption key found (checked \$SOPS_AGE_KEY, \$SOPS_AGE_KEY_FILE, ${key_file})."
|
||||
echo "Generating a new one at ${key_file}..."
|
||||
mkdir -p "$(dirname "$key_file")"
|
||||
nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -o '${key_file}'" 2>&1 | grep -v "^Public key:" || true
|
||||
local new_pub
|
||||
new_pub="$(age_pubkey_from_identity_file "$key_file")"
|
||||
cat >&2 <<EOF
|
||||
No sops age decryption key found (checked \$SOPS_AGE_KEY, \$SOPS_AGE_KEY_FILE, ${key_file}).
|
||||
|
||||
cat <<EOF
|
||||
Place your admin age private key at ${key_file}, or set SOPS_AGE_KEY (inline
|
||||
key) or SOPS_AGE_KEY_FILE (path to a different key file) and re-run.
|
||||
|
||||
A brand-new age key was just generated -- it cannot decrypt anything that
|
||||
already exists in secrets/*.yaml, since nothing was ever encrypted for it.
|
||||
That trust can't be bootstrapped automatically (nobody can decrypt a file
|
||||
for a recipient that didn't exist when it was last encrypted).
|
||||
|
||||
To actually use this key:
|
||||
1. Have someone who currently CAN decrypt replace the &admin entry in
|
||||
.sops.yaml with this public key:
|
||||
${new_pub}
|
||||
2. They re-encrypt every secrets/*.yaml:
|
||||
sops updatekeys --yes secrets/common.yaml
|
||||
sops updatekeys --yes secrets/nix-cache.yaml
|
||||
sops updatekeys --yes secrets/server.yaml
|
||||
3. Re-run this script.
|
||||
|
||||
Exiting without making any other changes.
|
||||
If the key is truly missing (not just mislocated), this is a manual recovery
|
||||
situation -- generating a brand-new admin key won't help, since it cannot
|
||||
decrypt anything already encrypted for the old one. Each secrets/*.yaml is
|
||||
also encrypted for its respective host key(s), so a running deployed host can
|
||||
still decrypt what it needs -- but the admin key is required for re-encryption
|
||||
(e.g. adding new recipients via sops updatekeys).
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
@@ -133,10 +126,17 @@ discover_targets() {
|
||||
}
|
||||
|
||||
locally_managed_hosts() {
|
||||
for f in "$keydir"/*_ssh_host_ed25519_key.pub; do
|
||||
[[ -e "$f" ]] || continue
|
||||
basename "$f" _ssh_host_ed25519_key.pub
|
||||
done
|
||||
{
|
||||
for f in "$keydir"/*_ssh_host_ed25519_key.pub; do
|
||||
[[ -e "$f" ]] || continue
|
||||
basename "$f" _ssh_host_ed25519_key.pub
|
||||
done
|
||||
local d
|
||||
for d in "${repo_root}/vars/per-machine"/*/openssh/ssh_host_ed25519_key/secret; do
|
||||
[[ -f "$d" ]] || continue
|
||||
basename "$(dirname "$(dirname "$(dirname "$d")")")"
|
||||
done
|
||||
} | sort -u
|
||||
}
|
||||
|
||||
add_keys_json="[]"
|
||||
@@ -146,13 +146,15 @@ dry_run=0
|
||||
queue_host_sync() {
|
||||
local host="$1"
|
||||
local keyfile="${keydir}/${host}_ssh_host_ed25519_key"
|
||||
local has_local_key=0 has_anchor=0
|
||||
local has_local_key=0 has_clan_key=0 has_anchor=0
|
||||
[[ -f "$keyfile" ]] && has_local_key=1
|
||||
clan_ssh_key_exists "$host" "$repo_root" && has_clan_key=1
|
||||
grep -qE "^ - &${host} age1" "$sops_yaml" && has_anchor=1
|
||||
|
||||
if [[ "$has_local_key" -eq 0 && "$has_anchor" -eq 1 ]]; then
|
||||
if [[ "$has_local_key" -eq 0 && "$has_clan_key" -eq 0 && "$has_anchor" -eq 1 ]]; then
|
||||
echo "SKIP ${host}: .sops.yaml already has an &${host} anchor, but"
|
||||
echo " host-keys/${host}_ssh_host_ed25519_key is missing locally."
|
||||
echo " neither host-keys/${host}_ssh_host_ed25519_key nor"
|
||||
echo " vars/per-machine/${host}/openssh/ exist locally."
|
||||
echo " Not generating a replacement -- it wouldn't match whatever's"
|
||||
echo " already registered (and possibly deployed). Remove the"
|
||||
echo " &${host} line from .sops.yaml first if you really want a"
|
||||
@@ -160,21 +162,26 @@ queue_host_sync() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$has_local_key" -eq 0 ]]; then
|
||||
if [[ "$has_local_key" -eq 0 && "$has_clan_key" -eq 0 ]]; then
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] ${host}: would generate host key"
|
||||
echo "[dry-run] ${host}: would generate host key via clan vars"
|
||||
else
|
||||
echo "==> ${host}: generating host key"
|
||||
generate_host_ed25519_key "$host" "$keyfile"
|
||||
echo "==> ${host}: generating host key via clan vars"
|
||||
clan_generate_ssh_key "$host" "$repo_root"
|
||||
has_clan_key=1
|
||||
fi
|
||||
elif [[ "$has_clan_key" -eq 1 ]]; then
|
||||
echo "==> ${host}: clan-managed SSH host key already present"
|
||||
else
|
||||
echo "==> ${host}: host key already present"
|
||||
echo "==> ${host}: host key already present (host-keys/)"
|
||||
fi
|
||||
|
||||
if [[ "$has_anchor" -eq 0 ]]; then
|
||||
local age_pub
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
age_pub="dry-run-placeholder-not-a-real-key"
|
||||
elif [[ "$has_clan_key" -eq 1 ]]; then
|
||||
age_pub="$(ssh_pubkey_to_age "$(clan_ssh_pubkey_path "$host" "$repo_root")")"
|
||||
else
|
||||
age_pub="$(ssh_pubkey_to_age "${keyfile}.pub")"
|
||||
fi
|
||||
@@ -299,7 +306,7 @@ cmd_remove() {
|
||||
local hosts
|
||||
mapfile -t hosts < <(locally_managed_hosts)
|
||||
if [[ "${#hosts[@]}" -eq 0 ]]; then
|
||||
echo "No locally-managed keys in host-keys/ -- nothing to remove."
|
||||
echo "No locally-managed keys found (checked host-keys/ and vars/per-machine/) -- nothing to remove."
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -308,7 +315,9 @@ cmd_remove() {
|
||||
for host in "${hosts[@]}"; do
|
||||
local registered="not registered in .sops.yaml"
|
||||
grep -qE "^ - &${host} age1" "$sops_yaml" && registered="registered in .sops.yaml"
|
||||
printf ' %d) %s (%s)\n' "$i" "$host" "$registered"
|
||||
local where="host-keys/"
|
||||
clan_ssh_key_exists "$host" "$repo_root" && where="clan-vars"
|
||||
printf ' %d) %s [%s, %s]\n' "$i" "$host" "$where" "$registered"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
@@ -325,7 +334,7 @@ cmd_remove() {
|
||||
local target="${hosts[$((choice - 1))]}"
|
||||
|
||||
if [[ "$dry_run" -ne 1 ]]; then
|
||||
read -rp "Really remove '${target}'? Its host-keys/ files will be deleted and it will lose access to every secrets file it can currently decrypt. (y/N): " confirm
|
||||
read -rp "Really remove '${target}'? Its key files will be deleted and it will lose access to every secrets file it can currently decrypt. (y/N): " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Cancelled."
|
||||
return
|
||||
@@ -338,11 +347,13 @@ cmd_remove() {
|
||||
apply_edit_plan "$plan"
|
||||
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would delete host-keys/${target}_ssh_host_ed25519_key(.pub)."
|
||||
echo "[dry-run] would delete host-keys/${target}_ssh_host_ed25519_key(.pub) if present."
|
||||
echo "[dry-run] would delete vars/per-machine/${target}/openssh/ if present."
|
||||
echo "[dry-run] Nothing was changed. Re-run without --dry-run to apply this."
|
||||
else
|
||||
rm -f "${keydir}/${target}_ssh_host_ed25519_key" "${keydir}/${target}_ssh_host_ed25519_key.pub"
|
||||
echo "Removed host-keys/${target}_ssh_host_ed25519_key(.pub)."
|
||||
rm -rf "${repo_root}/vars/per-machine/${target}/openssh"
|
||||
echo "Removed key for ${target} (host-keys/ and/or vars/per-machine/ as applicable)."
|
||||
echo
|
||||
echo "Review the diff, then commit and push."
|
||||
fi
|
||||
@@ -352,15 +363,18 @@ cmd_regenerate_all() {
|
||||
local hosts
|
||||
mapfile -t hosts < <(locally_managed_hosts)
|
||||
if [[ "${#hosts[@]}" -eq 0 ]]; then
|
||||
echo "No locally-managed keys in host-keys/ -- nothing to regenerate."
|
||||
echo "No locally-managed keys found (checked host-keys/ and vars/per-machine/) -- nothing to regenerate."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "This will remove and freshly regenerate ALL locally-managed keys:"
|
||||
printf ' %s\n' "${hosts[@]}"
|
||||
echo
|
||||
echo "Every host above will need its new key baked into a rebuilt install"
|
||||
echo "image/tarball before it can decrypt secrets again."
|
||||
echo "After regenerating, each host needs its new key before it can decrypt secrets:"
|
||||
echo " • Already running: push the key before rebuilding:"
|
||||
echo " scripts/secrets/push-host-keys.sh --all"
|
||||
echo " • Not yet deployed: rebuild the install image with the new keys baked in"
|
||||
echo " (see docs/auto-installer.md)."
|
||||
|
||||
if [[ "$dry_run" -ne 1 ]]; then
|
||||
if ! confirm_typed "REGENERATE" "Type REGENERATE to confirm: "; then
|
||||
@@ -378,8 +392,8 @@ cmd_regenerate_all() {
|
||||
apply_edit_plan "$plan"
|
||||
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "[dry-run] would delete ${#hosts[@]} host-keys/ file pair(s)."
|
||||
echo "[dry-run] would then generate fresh replacements for the same hosts"
|
||||
echo "[dry-run] would delete ${#hosts[@]} key pair(s) from host-keys/ and/or vars/per-machine/."
|
||||
echo "[dry-run] would then generate fresh clan vars replacements for the same hosts"
|
||||
echo "[dry-run] (not simulated further here -- run without --dry-run, or"
|
||||
echo "[dry-run] preview a specific target with: $0 <target> --dry-run)."
|
||||
echo
|
||||
@@ -391,12 +405,23 @@ cmd_regenerate_all() {
|
||||
local host
|
||||
for host in "${hosts[@]}"; do
|
||||
rm -f "${keydir}/${host}_ssh_host_ed25519_key" "${keydir}/${host}_ssh_host_ed25519_key.pub"
|
||||
rm -rf "${repo_root}/vars/per-machine/${host}/openssh"
|
||||
done
|
||||
echo "Removed ${#hosts[@]} host-keys/ file pair(s)."
|
||||
echo "Removed ${#hosts[@]} key pair(s)."
|
||||
|
||||
echo
|
||||
echo "Regenerating fresh keys for every current flake target..."
|
||||
cmd_all
|
||||
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. Commit and push .sops.yaml + secrets/ so the remote flake is current."
|
||||
echo " 2. Push the new host key to each already-running managed host:"
|
||||
echo " scripts/secrets/push-host-keys.sh --all"
|
||||
echo " (this also prompts to commit/push if step 1 wasn't done yet)"
|
||||
echo " 3. Run nixos-rebuild switch on each updated host."
|
||||
echo " 4. For hosts not yet deployed, rebuild the install image (see"
|
||||
echo " docs/auto-installer.md)."
|
||||
}
|
||||
|
||||
main() {
|
||||
|
||||
+179
-87
@@ -1,135 +1,227 @@
|
||||
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]
|
||||
nix-github-token: ENC[AES256_GCM,data:OfNRGJg16Ede6EilWUetCs9za+xk5/Lsa3SpVajsqz8PMdA1xQNeCWdX7ZAMdijHClpBhU6ETFGsXvt41O9aORS951uijeGSW7/NH35/bnPISrKdYeBx/+xEiqwH,iv:QGU3v7xOy89uzRTCb1U9ICyJ8XYIpXrUsDt12aL3g2Y=,tag:Bde2wcWNv8H4WLxSEUAodg==,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:OWmSRkZjb11y0Y8GdobqiE9GFwzdHOvvxCbYx69qUghGYARN,iv:i/JhGH0O7ThxPkL0SLAjfN0Fq8prm7tybI5kF2NRNpw=,tag:dBcqxOSHTnD4xngpOog55Q==,type:str]
|
||||
nix-gitea-token: ENC[AES256_GCM,data:HRQ8ymx/D8pLcL/pYIhcSTv3tlBCKOU5nhXPp/W6rNI+t+yxfOoRPQ==,iv:0Av3lrxQew2bDFf67nX/UM+cD0/8CtbQ2fZSaKWHAzM=,tag:0K+atsB/YmYd/qCN3hMdBw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIakJkWDQ2TjVIS1Q1UlRJ
|
||||
elVLNmhEVUpMNjROWmN6VC8wTnZzeWhLb0hFCmxmTXp3ZVdQMUhDeGlscThSdFhP
|
||||
eWNZSkFpeEg0cUUxbGFyQWIrTkJkc0EKLS0tIGRBTHdaZ2d5eHhxd3BtWVBLbFlo
|
||||
OVJlYnQ0N09qWFp2TmtXb2E0Wkk2bzQKTxA4rfkF3qlGpDqaZF/J9tgTx1UZ7ZFt
|
||||
W1KevMq/Klnkjb9XDuTEx7zgrjILoViVspe6eGI14myBNYtAP4nLSA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyR3lnUnZwUnlJTkFaeVdz
|
||||
T3Q2eW52cWI2eTlhSFpHNDRHTEVNVGZJYm1NCldaMmlHaCt1K3lWRHhEZExHK3lo
|
||||
azZFck1URVg5ejBaRVdCWjdFMTc4dGsKLS0tIG9mNkxsZXI1N1hCRWk1NTZKcUUr
|
||||
SlJoWGdWbXhqZEJHM3IzZmZSQ25QbzAKPzBIA/IJiZr5NpOhB6IPkUSDGQzPwpTU
|
||||
vgFLMze8OSEviaGXKLt/ZwTXHsr5As7V9yGvJKJHhS/hzuSLRjs8rQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUWXYyUG5UdHdFa2NDZmtt
|
||||
ZG1GSmxVbTg5Qnh4UGxJaitmTmFqK2J1cUV3CmdPSU5GTC9hWkROTkJzMk5Tcmpw
|
||||
ODJzdlIvNkp0ZXdZNlRlUHBkOGVoR1kKLS0tIGsvUWxpU2hkanFNaExJWGozMTJL
|
||||
bndPNkpQNktmQVNKNEhGOEtHSlRBRXMK9rr6NHf3H91GIqTmckjD1eV94FW1vk9G
|
||||
h2KauyebWPyBe8hsCExiPd45ZGqKF0g8pEtWUzonMq9NU/MNVL8YVQ==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpYmZHNWVCL0ErNEF3OTlG
|
||||
TTJDT1hZMXJNNUdLZ3ByUDNndVpLWVVGUm1RCkZSRmJmd3NyeHc0V0tpZEdYakF1
|
||||
WXhyWUJ0V1hTYzhrSUo1ZUNzR2J3OVUKLS0tIEthYXVvNVRibDVSQ2N0NWt3TmNP
|
||||
cTlteEtRYmxnUmdIUlBTREZzQXBBSVEKxQq10KDseuoVPe0cLBbk1+weuq0y6di+
|
||||
wJjaMShqVQBUI+MFWiQokBhPt8gZS7cs33LkWf3BegNALLDd6HXoOg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age19gfn2yedg76dmztm4hncr7vf3r3c9j0qpt4rap7y7gersjk4m3ks2lhd0e
|
||||
recipient: age19m0m7vdfg86yqy8l5mmle5jdd0unrn3f55t232w8h5ey42cqw34sfpt32n
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXdnpGR2ZnaFRSUUNlOUVy
|
||||
ME9pWkFJRTl6ampuZTR0QlQ2TUVEVHV3dkRFCmxML1NSV2dwanRPK3AySDJiUU9P
|
||||
a2VKZ09EaktYU2xvWWpESWtrU3oxalEKLS0tIEt1aWxuNmlaV3l1OGNqS2RtMmhm
|
||||
Rk9SUEpBaXY0RG9uMk53ejdJdVZ4NDQKfn4paPsHrfU3Ki2AgPBB8aLBbmD2yh1O
|
||||
9rDxv/6xSsDXNTquP11smPOKsRG7mDMDHVByn6GieZrpSxUf9vu3Iw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOE9oU210Vkk1Z1VKMS8x
|
||||
YTJGbFlSYmx6eWgvMXExZ243SndXWkZ0ekY4ClJVUTFSVkRERjY1UVZMZjlFeE1P
|
||||
Vm40WHY4UjMwZWxVa05lblBMUW9iNEEKLS0tIGVlbDVxakR4MlBEaFlrM0tNOWRX
|
||||
elIvMnp5NmhnYVBYOFA0aUdtOE5DbEUKy+soKNLlRe0SC8kcnwrpKqvSrTGE114/
|
||||
FaX2829gQWm0bYI0M4ixeTc5ME2O2Ct2tvYlzfZQPnAuub+jVx2k8g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ll6hj5ggruetgjwjfnplpn5xtq35uhlcdflksx3xmnjm6s3uad9sz70jkf
|
||||
recipient: age1rrxqea6q6pn39sw8y5te63h2py8jgjl9v0jyper86w3ggtn67upqg3ah39
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArNHZpMDV5a2JCN1NxY2hF
|
||||
MWxtNUNVQXp6K3lWYndTRU85Wjh4YlN3UFFrCnNZbHA2Z2RUSmxITm4yeU9rYnV0
|
||||
TTFkZUczcTZLU2pJRDQ3TVl5ZGErWDgKLS0tIDJGbzZZNjQ2RXUzRmUwKzdiUHlK
|
||||
TEU5VERpZ1p3bU1KcnEvRDRCSVNqT2MKQPfu0lskXaEAYgecmN1a0kPHF+fGEm7R
|
||||
wiY7TFNLeXM5aJqEnKARtOotJDetI+9VNssTT21X/Qaik6fqgM+b1A==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzR0R1VEc2eTRIOWNxaVpa
|
||||
RXVaNkxjaDVDTmxMVWJEU0dWNkxDeE56c2swCjdkZGVQM3V0SnhGMXFxSEFEeEpP
|
||||
OFNwTWQwYTY0S0trMy9FMjNjbWFxajAKLS0tIHhjUW1lZ1EycUh2Sm1yanhzS3dP
|
||||
WFlMcndzOThUVVVDZlJJeGFBT2JLTEUKHsJ6cwSPcO0IB1CQe2RqKeid8Q92BTNF
|
||||
RfURqE7Curj1yaFB45mzv2ThBgTKN5FE6y5BWgBnF6+szdMMXRw4uQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age120le4a5l8dh3lyfgvmj3d9ksmej6ajs5mer5y7r0vfg3x9fn69dqf8xgzu
|
||||
recipient: age1adur9g330gua4l6ndk8cqjg35qc8yxwgme6wrl2hpylcc7vxm38q05ejuy
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOUlp5MmcvSFRnaVdWZ1g0
|
||||
NmwvS3pGa1JCd0RuQnFMeU5wZDZYK0NQR1FJClF0Z0VENThtTzZ1OUtmUjdDenV4
|
||||
Mko0T2o4UzVubVRCdUlCMERyV2w5WmcKLS0tIGZUallPeUhXUGtVMmNIWWRkaFVT
|
||||
TVNVTjhiQzJkdlVQN3p1bnR1aUYyQmcKVnqbCuaSYEA7stk1MyfCzRbqt7EL+E5/
|
||||
jgxraiFmaZqjDI5mxG/e7eFdXfv53AtKzZm92TlzvV+4bfidj5SvYw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlQkVPTHBYWkdMM3Jyd3NW
|
||||
bkNGa3VtYXNBanZPc1puSGdwUEN5ejIwRkFJCjlNNXFlTEV1eEZNVTFxWnNJZG5O
|
||||
UG5hci9XVmMyeXF1ZHF2Rm5DUVdPcFEKLS0tIDBEZUtZNXR2R093OVNNYzRnbXlj
|
||||
S3R2UzFoTmhZT3Y0K1d4STFjU2RRamsKU9LcaOLLjmcarmdir9Hnt/qaNvlxvSsE
|
||||
RdXIdOKuaQqJyJ1VEpuDCfuZgtIdkr7OG1360giXFUIEUDliM9OPiA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1jy444f9d9stygj4p3w9kh54cqcfr654tvr75tdvee5cxsgtdtc9q3v60ep
|
||||
recipient: age17e89ty6p0fw24daanen57wg8uald9s025t3wwxsw269svwpmgvrshfvfvt
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCWTdCWHhTamI1VHpvUzZ3
|
||||
M2VnV3NFZTcrc2Jzazc0V3NsMDQ4RmtYZ0ZrCmVhV0pweTVLdzBLRnlJZURwNXVT
|
||||
YzN1aHZyTmRqc0dtYjJWekRaODZoR3MKLS0tIHZPV2ppSTVwb2VHWStRY0pKSTZ5
|
||||
MXVFSE1odzRMeW9LQjVQUytBTWhHUDAK+UUowhIQ3w5O4Y/m9Pq41X2l0DZQfzOT
|
||||
itFkXJRnvUmkCxWYRDFJjQ6FpHHaNDqs0BlQZ5QZ+chzcKuUAG+uEg==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWUFJETUk2TGZTdVJqUTcz
|
||||
MUZvUWhmWGR4SGVxOW8xcy9Pd1A2ZnhPcXpzCnhvL1lPTmZJclJ3OHVqbGtGZFMw
|
||||
VysxdnY5ZzgwUEFiVUxpYStUb3NsTEUKLS0tIHRpS0dpb1ZNMXBiRUhFVVZiSHQ2
|
||||
UTRoTXFjNVFOeE84WXdsT25UQUprbE0K5U8S5xojEgUn8pAgY6X+Njllv/jqm/Qp
|
||||
tqeTbMw+w/afAhRxY80x/mTJdCAxUh4guLTKO6eojHYcFvwT6eZA4w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age120whqj96g26lsgy4udvgsn8dc9lumh8jeu3a564fx79rjr5lxffqmrljuu
|
||||
recipient: age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2MEZxZTI3Y0xWSDdzanRC
|
||||
RytNV2x0UkY0SnRzVnR0eFNvaDRJeUtpZjBjCko2T09qSTJsQm51aW9pR3JUbkRm
|
||||
NWRXODg0L08xdGNLdCtORXA1VWRteTAKLS0tIC90bkZEd2pOYWFCeGg3UW54TWxH
|
||||
Y1liQWVWS0t4WXBROENGNnpDVUpxRVUK57qCQ0l5Gw1ZqM50XSBNwVlXkue3QOT/
|
||||
BPmPFkMNpVIcUDENj+mJAS5GHuEm1MkZcMi/wN9Hp2KE05ZywPR2Zg==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpY1FUZ3RKU080RVNPZFZ6
|
||||
cDExYmhLdlU4K2xWU213MkZtUWY4elVxdlF3Ci9LK0cxV2hKbXZEMEd5dzVCWGVU
|
||||
SjErTmpYbDhmcDlSSENhNnRrN1QrcTgKLS0tIFRlbVlKOW51R3ZiZ2pLd0JXdks0
|
||||
b0FsZ3VETmREVEhqTGI4Vm5KQnZaWjAKXq+8u2Qk84Vt+eDUxzE6sDk4DDm78P7H
|
||||
KVnrZfhAmwP3X7dSuBhW+dK8in8D3jaqRK50d/eHUUWX0NIqniUMKg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xjst4frdh0th6q8m7p7u9g5af7ty5jqeum0p6z8a52a9q7st7ewqw8yl9j
|
||||
recipient: age1e7l8dusgmgfzd2cxrrzwepzjxt69hzqj4epee0cs27u6yg4kxcuqm34ncx
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCaGhvbE9EU1dMZitXYlZk
|
||||
NVBEYWxHbzAwQ1FUU2JidjJPZDZLVDZLODI4CjYxaG9OSlVOeGQ5SmlGNWEzNG1n
|
||||
WU9PRVpVRk9rd1BvWDNnMFcrZFNJZDAKLS0tIEUzV0E3OGE5cWM4MjBwTk9Mb3Np
|
||||
cDJQZGNqNXlySjRLcWF4R0V0ZS9DVFEKS0CmXOfJ1qpUF24EJT6F5/6xzR7h593O
|
||||
mLiMdOCeFYWlTtBwrD1dkweAzStiHlzTVEq3w9BFvAsr5x/NbQJUJA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZU2R4VVVkWTdqS1dxbDhv
|
||||
K21KeWs2NVo4UTBvNEF6SDRYdE9WUldYUmpRClloR1lBRU9UYkpRSzYxY0NsR2c0
|
||||
WHlXT0MycWJPdHhHaThWUHltdGZYQXcKLS0tIGJuaTEwSnpmMnFGVml4UUs1ZCtT
|
||||
U1V3U2NNN0l4L2JPRHFDOWpWWE9HaVkK/i5m6YFiAR6xtms/pbcDNhKaZreqIpjT
|
||||
8tvnqHz2HDSuCMAjAnZfluvuP1USHvjJQGZpBfreZ/XGhW0oqa7D7w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age10at8862478urh0eeuwh8hzln6ck78jgwtztgxatwqlzwagg77y5snm4xzg
|
||||
recipient: age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzOUxYa2xOYlVYd1lWclR1
|
||||
bDJFTEg1SjBhNjhFT0IwY0ZxZFRFTzRUVlhZCmpwL1VBMElZNzV3UkFCV0FWb3Jr
|
||||
SlN2dTNmOFRXeGJVTFgwdjA4SmdSQXMKLS0tIHZOdjFzalRkL2N5eU1iSDkzbHYw
|
||||
NEU3VWxBNE9NZy9hVFJwM0VnQ2RIbjQKc62J04UtVjqiU7p7GueMicdCDRTvM9zY
|
||||
IPOJSwTCatRMWeuBJIsRNkbyOLeSAesQdfAXL5GoAE8mBtBdhJKaLA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKNklENW8yZ3VlMytlVHJs
|
||||
dUhDcDI0amdZWlhmN3UzbFJyR2x4RGVOdFNVCmovVzFlYnh6YWxYTnYxVzBNejVT
|
||||
MGFhdjZzU2hnMW42d1RucENGeFdXa28KLS0tIGhIZy8rZ0pFM3o0cDBKTXQzaFNO
|
||||
bG9XNzFGdVNyczRhWjRqQXlxNHFvL1UK8NMj76782tmIdJJ4qIzLicFytNhj6ZMk
|
||||
HIIOJGCrBnqgCtcKTiCrTGRhGbqGzhkId1oJkZkhMFRoU7kdSvwD8Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ezk9x53zt8kcnscdm80jcyf0xq97vndv7jsn3rl8cc0cwm2jmpmq372dzs
|
||||
recipient: age1f7usptjx9rv4rxauasve200gxtdt9jkqhhdqstlf20wvlm7u75rsjfw50m
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1NkppWjNXRDlsVXZ6b1ND
|
||||
OExFRXVOcXVSWmZXemNoK09EcXBHZ0JMYnd3CjlsaEM4MjdJT1lBZGFCZTNISHZV
|
||||
MjBRTUlTVkZBQitFc1I3ekRLN0RtYzQKLS0tIC9oelk1TTRzeFNpbWQzL0pObXJq
|
||||
eWxHbkF3anVPRFgyQ0kvSlJqQTdLQW8KLfZ+UIzas43ROGO51KKOmy58R2tl3jwh
|
||||
M+1WXB8vIXA7DLQ6vMbzMPMhBnJ4kbZgRtxELg7fzSLQStLvkY+O9A==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzbFU5YWREdFpsK3NScXNU
|
||||
NUFMMml2VEVOQU5pWkIrUjkzM2tZOG9vcTNRCmR6Vzc5OWRQaGdvU1Naam51dXhD
|
||||
ME1yNFJObktNNTA2eXFsNExTL3JNeHMKLS0tIE13L0xYMCt5TE5ac3FEbkxVWmVv
|
||||
V3NCSk9LUCtPZFZCbVNmSjR5QkFKTmMK5qFJXtZCKLjOCg1r+sVQpMKl75GNcrbI
|
||||
Dum/K/3HU03wv5reG51UDsQ1tMrsFDsaFh2fjR+LxLKSGlG7b3Rz4w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1fxxzpnfse8nd9wz78ht3m0plrmraacf4cpga0pe8fm2tdnqcgy8q7qsyvp
|
||||
recipient: age17jqc66x9yeshfgd9v78mj483r4zzarqdtuxtrkxe4x5mw679gphshd94th
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWVm5uZU43cFFLcC8wVkZO
|
||||
NmlOMmlocGpwbXFkUFlCMEl5Tkx1QWJIdVRjCnFBTzdWT3NaM0N2eGlCbDV2NUU3
|
||||
Z3FnYktsZ3NHT3Y0b2hOd0xmRjlLMjgKLS0tIFBQVXVDeHkyeVNNek5CSldSMFlY
|
||||
a0Q5bHVlRHUxMWduMCtLRjdnMUw5RG8KDJX8I21+bUpkJ5wnX+kGhsSa8mKqsR25
|
||||
iNdOtVtb2WRxIAgyjR72yp1tLMrop9NkZN68MGvd/LsNM+6simyosQ==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEWFdCNGpPYU5pSXkzd3lG
|
||||
TGU1WkhuUEhmaDdybzFUbVVaVmVqU0FyQXhBCnRmRngrbXlsTVVMSDV6dkhBS1lD
|
||||
MmNUd0NjTE5ybFEweFhkWXdGbDNwTmcKLS0tIGU5Sk5hM3Y2bUVsS3pTaDZiaTBi
|
||||
S21vU1ZVbjZ1SHE0WStab1VHQUNLRUUKBDW9hwI90Yn+B2mB7LUNTVxFGbwEFSw3
|
||||
LGGqgZdvnN5p6NMigBbJz+kSwOk1gQ/yo86HtRj8Ejllp7P7jRsfmw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age190htw7prp4vln076dxjx3gxxaq06h0zl0te7cqgpx79vl3lhkaes8suy05
|
||||
recipient: age1px0h5l9zp2dww0m8fncrc82kfdmzplsfv2ltat7sna28xpg09pqqcl3s2k
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHcmIxZVc4aGRmNEhSY3Iv
|
||||
YXhzLzduWFdzVVlKZTR6WExzcDNtSWQza0FjCmlsZDVJUWpqY0pRSktQeGk2SlAy
|
||||
cjZXL0JCVFRtQXpHNkNIejhyTnVtWjgKLS0tIHBSZ1JyMFBJVWZ3VWFLOUt5OHd6
|
||||
YUI4ODhHc1I2UHVRQ0diSnhsSUhDelEK3hBBX1+Uwe/MusLqmt4oAy7Z6jOU96Cd
|
||||
7zTe8YyLc0/DUDsyuFZ7a68riO9/My+zrFllwtKe/JPmPHLY0+3Pnw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaMWdPVkV4WnNHNmU1dTZZ
|
||||
ekVoVTJWY3laQ1ROM1NLUnlZY2FKTnJwNkFFCm8wVWtoNFpxMS91RkxzQWRhOWRs
|
||||
V3k2STdvN1JRdjIrSHVFaFpSYmlmT00KLS0tIExnSmw2anNtdzVGMjRYdzdpcFRq
|
||||
alFrUTJVckpTVEVIcUxTK3pPbWNpUzAKSe3Z9V5u8+om2s+HqUcx6qXIaTmQUgOT
|
||||
zIrU8T0tLOHzdS1SAdh1Yb50yevN+P2+5TORdLjrtigpyzeYc2U/zQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ukpqxzl44mnjpy5r96sfuc5sqzm47u4k8ujjh5qdgy6jvl9uqgpspymqfk
|
||||
recipient: age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSE53Yy9FOHNpeVFtTmF1
|
||||
UUExbzJlZktxMGR3Ukora3RpbVN2THlyVjNJCnFpWVEyK2U1N1diOWZQWm5Bb1lD
|
||||
djV0U3QxamRJY3BRN3hBTmZ4SVRiV0UKLS0tIFU2Q0o1UTRjaEFaSTRVTm5MVW5T
|
||||
QXdEbVB0Y3YwM3R5dGp5d1oxeUxHNGcKRj5hNLlXtZoT3IwXHTxaReJLu8k133n/
|
||||
ZoCtv470LPL1M/kTjdPc/nWMgYOHDKkO90gr3WILfHg8idkVuCLT2Q==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtWDI1Z2hVRG9ieHkyZnlP
|
||||
SVQ3VHNsSlZxMjdIMUsxQWdIclNQQk5OUWhzCnZSMzFZUGkvYlNSRTJiT1ZMU0h3
|
||||
L0RIWkt4R1F5bUlWNVlQQklKc0tGa3cKLS0tIEJpTXNzZkFUOWp1ZnFocjJEVExa
|
||||
dGlqRDRLd1BDYzYwSWZXaG1ueXhrSzAKdJ8yA+igZKyetNuQdoN2Woi2bl2I3Xj7
|
||||
dDBVAn8bvxx5ZG1eyDXXcHSh76xUp5ZPRlLOFCvRK9oOLWd8FntBmw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age15kh7akxlx7zn00tey79rq2g8lgs4j5y77rcnyfxrxap8ckfu0a9sqvtdhh
|
||||
recipient: age16j42pdc5dr6wnj7xayhkqdj2rny9u68fcqejs50hqq42scssh4gsnrrnlt
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQazFqSWpaVGtkNFhPU3A5
|
||||
cE1oVXEvSU5RNnY5NU9mbFhGOFQycVFuL1U4CnNwRnVkYmxCNFZzNVZvM2N5eVN0
|
||||
clNjUWY4RHZxbVNsMFlFSnZKcmI3dm8KLS0tIFBDZTRuRlprdzdVT1ZHejNsNDBJ
|
||||
OUVjclpPSjl0RGtLVlZRSkpFeG02KzAKt0rcJunZppojjijrjbXsztLwRD9pgWRb
|
||||
jYsn7dCvWGFZGVgeyxqfL0jfYNz6dW0yecRE/gbP5hIooxbcPcxfag==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkb3RrRDNqTGZGYlR1eTls
|
||||
Y2FYQWxncmJBQ1ZiNWFpMTBMUmtlSnJzemhVCk5NTWZRcTJMWkIrTnVsbVAzcndB
|
||||
MVp2NHJSazJjMVN5UXJHOWpnc0dLK1EKLS0tIGJlelUvbEZZZkFKd3BpdWx4VVNH
|
||||
cHJSVmJ4ZWlVTlA3VXpWeE5DNkphcWsKTGpdWU/cE8vC/43lwmnwJDh4IPqHQoVV
|
||||
yjnUdZnPDGyQHwwrydVgun2fdaAKH1zxAut8TZlH9pxD4ir0B69G+g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ehkswwz2pqaz4svzh7ela5tdnssl8kn6d4vwwxd6zwg8exfpd43syyrrjp
|
||||
lastmodified: "2026-07-19T02:30:40Z"
|
||||
mac: ENC[AES256_GCM,data:UiL3VMDF6rq4Nr87KspcDx434q3tfNXeb5pwH2O+4ssNQ6xzcYDdzXBnhAY3zLBsqPMKrvHBd4Ot/gEMcq3FMIVe7Q6p9yWKpep66KZ/yWEhAlwIVhD79Oj8VS+1CHKjf25zpRdhZorp04oeFQQd9VfjJB4EE/Q1aVbwTGlpIic=,iv:i/0conaFgFia+wzNTdUL6tlSTw35HTK3Ap1Sr5RGHf8=,tag:ULbz5FllShA/JjlSRdxA0g==,type:str]
|
||||
recipient: age1k7d2du5mejsmv5rzavm4xwgpthqvcfsehduquv28nzs53zppa3kqngfxq2
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaZ0tVT09Sd1hXVHZPd0tD
|
||||
YmZKaStnUXg3cERMTjRmOVh2RmxFbG1FZ0hJCjNMWWJmaUFqTURoL2NvRnJIdzNW
|
||||
OWFmSFAxWVRrdit6S2lpaFdERGNrOWcKLS0tIEJlVVFBSE5PK3k0dTRTRlZYMGZt
|
||||
elcxVlltUnJVd256NHQ4TithUmFnSkkKkbFSRRktuO+tNxlfIEuJ27C7LWalHJMb
|
||||
aCnjE9301XMceGB8SstPtGi5SJRSHD4WrJleeJbKXYMronfvWnYDSg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age16kqfmvz4e23hmdlqresnyw69ej604s320mmd49h4hm3fhqchtgyqrws0k2
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEa3E2ek9BcHZRRUR1cUpK
|
||||
UEFERzc1ZEtrYjc4bEdEeDRnSDNkWHV6elhFCjljdnJLa2syR0UzYTRveGhPYVk1
|
||||
cGNnWkNMNUVwbXFrOUZ1aE5UelFyMGsKLS0tIFNyQ29ieUpReW96OGhJaE4yU2Ni
|
||||
Y3VWNUFYTU5LdHlmd0krK0NyQk9mMVEKfh7I/9+V9+0DLkzTf6n4sBKs+oZlMlTx
|
||||
ar203b95cR/jjsUekF0NoZjj6MW1IZSV7BkaDoizVwrAxh7WS0vwqQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1arhf2q45zw6wf2uevju4savp575x3m2tfvved5zzq3ay92ynua9s3cm92c
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtSUcxeE5pM3NZVHlhcGNt
|
||||
d1EwODM3Wm5BOGNybkNMdnM0SnJmTG5ScEhNCk5yZHhieVN4QnNPMkxzbUZ3SS9G
|
||||
aG5WVENIbVBYRGI5ZzQ3QndUTG12NjgKLS0tIFF6ZFp5YWJ6UzlqMWRsb3pTS3l0
|
||||
ODRZZG9pRkNxL1Mxa1dwUG9vdkJZZTgKYrjkSkjNQCU2wMIuZMvJnd/regVplzMi
|
||||
3kPGRBMWzO1t4CSFtf2PMqv5AHw754I+vVNs5CpTXBuxVUcQDcGJCA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGNHY0Wm9oNk1BWFBiYTRm
|
||||
OXBxNU9ETDNyTEhyMnBxZFRnZmFjVGVHN0RjCkdPNWpvRkVMTCtWdDhlR3Q0U3Nw
|
||||
QUh6KzBVMU1ONXh2TTYrd0FaWlhCU28KLS0tIEhsaGxvcGFtaVF5T1NIc1NUOFBt
|
||||
d2NGeW9YNVVxdUpYbFRoVnhUM2VTUGsK4adI9pgC2PipcAY4zXMRf9hPv5kilTvc
|
||||
BsUNz0Qr4YdRfVfrPlPBzMCPOTDofTp6qBv+Gc8FE4hvwxtbjIcJ3A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBT25YN3BtMVZrVnhDazhO
|
||||
OXpzOEd0Wk5KUnZ4a2JjRGI3U0RsMExXN1FZClM3UFoyeUIzY3FVNnlkTU1NSCtn
|
||||
dXhNb3c4ZFlMTU5VTFc3blBUV0c5MkkKLS0tIHUzUzliL1NEUSt3cWx2d0tjRHcv
|
||||
a1Nmbm1MZEptQjJuZ2ROWGUzN01zbDQKy3dPKE5oYwsTwE2vnhUi3auqJ/KBOBPX
|
||||
vSKOROUuS/uEfsdo8NoVxuZ2RaJHetrbHAYPHFHOMgpe/X72pCcQNQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ug787sgt6st6k82fgkrug2lzltw4qsukrrqqs3w27ewwqj8rg4hsxcmylz
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2Q24xSkJaVDVUZlBWcW11
|
||||
cWFOdjhXZEtuNkJtaUhQTEN3QkVUQnRRRjBZCnY4V1lCOTBNM3Mrc09naWNTQndn
|
||||
VXU2ZWt5Zi9CbjBFa3YrejFHM041VFEKLS0tIHd5L0hRN00wdmdsSUhkdFpTcUNS
|
||||
YUNiUGhVR2M2clFrOUkxNW5idkR2cnMKXO02RJh4ew22tf5GWH4lNdLlhf4bWyef
|
||||
+hW9R4TGCJOnIO1xSGpBX0wJsM3oiW4qy/1tgfQw2ejVih5qUr7JRg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1zhfyuzlq40reuqlr34gf77852nhs3t6mqfzrqmas8z6sxk7tcfhsungrm0
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1SnhhMEpwOS9vUWVuT0Iz
|
||||
bWFCUzE4djFyUzN0alRvOG94bGJldUhlb1ZnCkxHd1pUU3B4b0ZhRklnczUyRzBR
|
||||
djF1bS9scjBlQXJEbmtiUFgzTndKQVEKLS0tIDRpUnhWYTM2U3lLcWZyVFkwcVNL
|
||||
NFpldXQraURraWZNdUNqZFNwVFY0WlEKhV8IZYbBXKGb0x+2E5pJkoMavH2ox4qp
|
||||
dK/XRlBpEG0SCUkKZ5sDegzz/HyqbxRF26jC3IOL1uR8A5nsnwC2Cw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEWmIvVG1GWEZmbnpFU252
|
||||
bFhOajA2dnJXNlFGMmF0TDRuR1YrYmZqc0hnCk85WGFUMGZXdElWU2tvdFV3eE1v
|
||||
czEyTHBkZXFnSi9MVWFQVHJabEpUb1UKLS0tIC9TNGZiQ0xYR1dQWHl6NTAxbzlD
|
||||
YmNhQkFVZTZidWFtbURqYnY2eGMxZ2sKaUPhe5mQ4QSyxuQMqLNI1jqfkCUBnBWc
|
||||
HR5ck3H80/H0pMwzP5uaBnN/hD5L4CT2FcZBgUygbyNz4G3VgbjaMA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlU3BybGcyckNaT2dQR0ZO
|
||||
dDVXa2NtNGFTS1RqZ0ViLzVJSk5PbWdYU3lVCkRZNTJ4MytYYVpON0JBQTh4SWRY
|
||||
ay9xdkViektHNGRuSFNSeVoyMFdrNE0KLS0tIGJ1QW5RUjhWR2NlclR2dnh4SUQ5
|
||||
SlJEWHhBTFI5Rm1rZVFjTGZlUWhZMGcKReNE3l+U35iutlQ07AZ+3fOFF4YdVbdx
|
||||
bR/Sz3NqpqmZqBEmgjUjjjQI4h4Xturv4tT8/JUzEmVnEyDsqCbhHQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age16y0vfts5v2k20e2rj23qa5lc5gm96gm64uwsqsr0y688xl0ne46qcfr0sm
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTMjhleFpqRGRBMGIrVmU0
|
||||
aXcrclh1TmtIV1JZSlNWRXRRc2RCYThyUVMwCmZOdXptWnhvajYxeERlOEJFVTU4
|
||||
WTY0R2NZSXplMDNNSzZ6eCtEeTN1NjgKLS0tIFIvYmY3VHM4T1RTZzljR2RnV0ND
|
||||
Z0ZhY2JpL0psb0RJcEJNN3FHUHJVQlEKJdoXKdvm6fGA+C21lsxY4Zq+VVPt/6k4
|
||||
vH0IirsPd9Cg33+tBzrySwkF+GVNtYLtekup2L61pFQ6/Y/necfQ2A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1tm3zj5lp3elw2j832az4nj9xxmhqd66ag3cqcv3vskvmd7qdmqmsdpdcn0
|
||||
lastmodified: "2026-07-30T07:49:07Z"
|
||||
mac: ENC[AES256_GCM,data:D5Y+zyLvs3gz8BWS3+lrra/tc1TsDIk7FykxSPiz0Er1jUB5LOe4IKircytMpwGqdLcVhUKgXYdRA+C0KAXaI9KpQp5qKfRDvX2jX24jveVNFPgbvxA2LidYasVMzA7ayTU0I+oirPcEtj+5VH/ahSjDz9YtarTDeHMpXvBZMLU=,iv:KcIGEs+61yS8Ul734fqsrC95iA82wIinf72DE2bLk/A=,tag:RzL48FqVcMro2W6VAgIxzQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.1
|
||||
version: 3.13.3
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data://9IEHIVCfHjyMNao5sCu2zNlZ/CaW+JyxVpGpD/vab2qvURunCUY7eMfSOyvOx/2WPXnWWlkoVJPCR1ec/yUg09EaSMfxrvqlu4UJI3Sxvu9xNuDszMwMMD/sCulJDiMWFNLp8qaYt7UGzexp4+GlGiqWDxk3ZEu/iLmSApzBrpciTF0lfehT4qblDovo9QXG2KDWFhCt2SwEKmHJ61Yl3pVAQnPLyTWaNhwWD/mYL76mIiDVKq7DJlvi9MBxzi3aYh/ttuHgRCvnCL0C9oUvOyT2cljwra0LXuOrum7FhPIXXboA7WTEbTHJm1LB5yLfxDrnWCrGxQzFQAwNixVHIcjuvjjvA/LifTvbj5FYM8x7an+DwXPfPhruFrDew1paAWsEGNNYXSmAlju2QLI/OwLHFFLuDyLnBKQ6RLtZbCEAOUKf2h4iZj7nH86eb/aGU=,iv:rj76+MJBCpiYyx2Ogut5UxJj3Gn+bygvu2mtuY5hFLA=,tag:yQmTvWVu8ZLug/7fo6RnwA==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqcURBS2tIMGl1aWxLMHFQ\nS3ZGVTE0cnYzTmpnQ0daL1F3QnlpemRCMmtjClJiZzIrc2syN29sRXVoaXlPRUNF\nakw4RndmbEduTFg5ZVRKUTVwRWpGRjgKLS0tIHZ5ZFlyODlGWTJoNGpXR3RhY0ZH\nOGpPdDZQVmt6UWZXbHkxQTBoeW1JencKbsfH1V1lUj8mmHyLNj36VaRDgaBojcDU\ndoQWmSEXxjticJqdadbVKb3UABpvzAZxASCy81sa3wH0gT+7zLaNyQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrbFdaMmRjcHgyV3Y0eWZL\neG1uMEtpQVUxQmNTU0Z3aC95Zi9uQlgvMEhRCmJiVlRTR2NocGlnbVU1UmlLVVA2\neERiMXpiQlVPNVhRU09XVGxrY0Ixa2sKLS0tIFRuOHdoelJxOXNRRHBJNnlhSE9j\nUUZHZmQzOVFwRmhFV3pvdnpRMTMraGcKRHBuSUpbHaEzH2tuSBE5MsLJDCuH3vUx\nO0jnDldCWkCw7Wvr/tQAkaDI8axZcYVDUkCEk+xqAdxDozLCPhEO6g==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age17e89ty6p0fw24daanen57wg8uald9s025t3wwxsw269svwpmgvrshfvfvt"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZMW5taXh2QkJkT0JjaVpq\nQlBjVFpFYUhXTHhOT3prbFcvUFB0YnRGUzMwCjhHT01lY3dPWDE2dDZWZnJza1hN\nS1AvZWIvdjZoYmRjWlliK1hiOEdrT00KLS0tIDljWWY0NlQveS9VR2hOSUNyTUtH\nK2gvbEVibmZSdktPemdEQ2p5U0Y4d2MKKitoTi2vbxJ41IoWMlj4vO91Ahpj0hHP\nrKCPyx7ws/IUMmKGyvDLpZ3pYqHD9jl5pLfB05Hh4Emhv4lA1qtwwQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age17jqc66x9yeshfgd9v78mj483r4zzarqdtuxtrkxe4x5mw679gphshd94th"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3K2dVV1BHbDk2Z3FhNmdV\nZEN2Um94K0pma1k4b1V5ZUJxQ1hWU1grZjJNCi9iRVVqOVpEMmtBUi80NThlYldY\nTUtZOVErT2VSWk43NndpVzBlL1lQaTgKLS0tIEpCM3Q4NjM3N1lpUE56N04rTXN0\nZkZ6TkV1TU9OV3dpc25RNWRpVnprM00KItUzKBdShakOfX8Sr+k906nsvYPl8QLb\nge//1GA+ukGsaS9rcChOY89vFdm61JDmj1jXSJ0CN4wLMW9/eblZRw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1arhf2q45zw6wf2uevju4savp575x3m2tfvved5zzq3ay92ynua9s3cm92c"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T01:44:28Z",
|
||||
"mac": "ENC[AES256_GCM,data:efFXIqbauOURR7lrVpK7kqRIPgYjgWfenBYoX7mUrQ/thFc+ApcAx58Fi1zg4biwIs7NarJAgDqAxZi+yKb2Sll8H/wlsEjWDU4iQlLJdIQw7wey9eDW8pgBLd+6E7FXrgn1Vh49dS5GXlC6clAYk1lCiB4NvfzPDy5Ktpl+Chs=,iv:+zCqj5I1MLJfRRiIrqgocYB49PZnlV45PUTH4gYlfrQ=,tag:WxY1sF4kFOTEzbSFcfJFbQ==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
+35
-17
@@ -3,31 +3,49 @@ sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrWFpRSURBR0gvRzVIMElk
|
||||
ZlJGZlJvc3lFOVJIZ29YbFp3akFCNG42TWkwCjhNM0J6cjIwRXBzc3VWNVpZSGdL
|
||||
Q1M1bm90OE1tTG9GbUxvS1dvRkZ0UGsKLS0tIE43L1dnbWhQOUhhYjg5bEIvZkVD
|
||||
Zkh1NDhvZjlDc2c0cUZUYVRLdEozS2sKU/r6JnEnUs2WPj/J724B+lgiV84iteZa
|
||||
uMlhgnwYJFLkH7ZyydQqjYcHL7xEInr0taYJN+M0nZIsZTvzAEi7iw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzTFFlZHdGUzk0b1Zva1U5
|
||||
V1UwREEwS05icWxYNEdKTE8rQ3lJMjM5YXdzCkw4OE4xWVUzVWZMaXg5OFo1UG8z
|
||||
WkNwK20yb09rV2VVSENwNWUvTmhJNk0KLS0tIGVPWUhFS2RDcTNjY2JLaWxvcTZt
|
||||
Z2RscURQdDVCMUdiVng2YWRMSlEvVVEKmyd3re6AaKn4gBjoT0x3e/zJznvJFYKn
|
||||
ugKu3EsUX+gbailPmY1ss9+MVtpJFGZa2FiM0x1wSMKm6UJH0aPhVA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXZmYrVTZxWW1QKzhLWThX
|
||||
UFAwSEhId24yMitmVk1kT215OGdqbGp4akFjCjRzN2dkTURXS0wzbzhYL0YxRW41
|
||||
TWJ3UExXaHNhSUxaYkVsMFNCekZHZDgKLS0tIGQrSWNzalhCbGJZdkxvT1N5ZWlo
|
||||
a0l0Nk9DY1BKYU9ReWovaHJ0Z2NyWGcKFoIYS1M4EbR6H6QG3Wjv2ZdX3r2W8zKp
|
||||
S9f578O5ZLh2OWaawcSb0oecZJykT9pgudVcuKRunzZN7NQvFxBRnQ==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtK2tud3pWSmd2aC95ZkV4
|
||||
RkVvZXNrK09ZVmZsYWhqZ3JlaXBCd1NRWGhzCk5lelhSN2N6N2VhWEVXUjhLTjZH
|
||||
V2VlMm5XdUtuK2d0MjIyRi9xeTh2ZXcKLS0tIDh5Rk1UT0dWblBkUXYzU3YzVGkw
|
||||
OFk0bkJ5RXZpWE4rK05QUHlLQktYSmsK/HsVEIhBEIo3qqVWdUJEWnHZiKB3uHVH
|
||||
R+nJGuXa2B/oUoxEwMP2YBHwwjLLiJCTYy+aQtiPdTrVq0YJ0HmC7w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age190htw7prp4vln076dxjx3gxxaq06h0zl0te7cqgpx79vl3lhkaes8suy05
|
||||
recipient: age1rrxqea6q6pn39sw8y5te63h2py8jgjl9v0jyper86w3ggtn67upqg3ah39
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlWnpPUk10YW82TVRzbk80
|
||||
WHJqbXVVSUFrSTVjMEdqUTdSQWwvdG1TcW1nClQ5T0E0akFtSlFGdC9WaU1LbGkw
|
||||
allOSjJ2SjQ0MkVMcVNtdzN3cW1hVzgKLS0tIERlQ2l3NitPQndERTYrNVA3ajF0
|
||||
bCtnYzhHcnZkalRNK1BHbjdIS2JLNmcK5RwFzeaK1KafO4cAtdFh5Tnz1lpZbj1Q
|
||||
aeMmTGMkxJYVGzXKS6SRspVT3MvY4Fvay8B0MezjG5Y5HKNIv22Vog==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpVVVxWE1nUDBLTVFUaGJJ
|
||||
Vm9EUldwRGcvbXhuT3JPd3N0WXo2S0gwRnh3CktabkdaSndaZUNSeUJGRzFKcjlH
|
||||
b0x0SFdEQ0VqNWdQcEkxb0drVVJNcVUKLS0tICtyVGRqUmFtRHV1SlpXSVd3N2VN
|
||||
elQrVFdhWTJ1R2pJbjdYa2w4NmRmc2sKJHqLYdNQJcna71KNhGF80iS1hIYG1U1w
|
||||
I2kihepJsrmYr76ld9k+u1ZfnuIuJ1ozYsZothE+dr4pV0k8s6wkpg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ehkswwz2pqaz4svzh7ela5tdnssl8kn6d4vwwxd6zwg8exfpd43syyrrjp
|
||||
recipient: age1adur9g330gua4l6ndk8cqjg35qc8yxwgme6wrl2hpylcc7vxm38q05ejuy
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSWXJxUEk5Mkp5eTZXamFR
|
||||
SXZ4L1JTSGNaQjFiVXFGaEdvWkV2ZzBlVzJJCmd4NmUzZEdCbi9vdWdkVGZRL2Qv
|
||||
cnVXa05xd2gzaXh1SnlMZmtueGpZMHMKLS0tIFowdnhFVGFheURQU1V2M0ZuM1Y0
|
||||
NTJFWXBEYUxmOU9ROTkxWGhYUmlqMHMK3pexvc16BLKjh2meqtNm3M1zyLQ3eEsz
|
||||
7C5WkdcSpCkW1lPDGtW7pEdAIL15StD4x7ut4MkSk0BjG1S+RpDbzA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkR0o0SFh1L2xjZ1RjSC9K
|
||||
ZnFyb1lJbnlMbENYN0VjSVQyU1F6RDBrL2hjCnRwTUp6Y1hJWldVeFdQZSttZ1Vw
|
||||
L0dCS0ZROFArb0ppVzB5WmV4bWI5alEKLS0tIDh4VDV2TFhhaUp4L09jYm52UCsr
|
||||
NGh5a3VMY2ZMZVBQbmRHeWsrQnZVWDQKR1UeSZ/EzZEXMqyjB1I2SHELv8Ha/tmI
|
||||
kJKs2WT1RtDhAiTrbty3f4oVrXWSYKZr40kNiP/RLbUcH1s65ys/tQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn
|
||||
lastmodified: "2026-07-22T01:15:21Z"
|
||||
mac: ENC[AES256_GCM,data:dC/oIqMUHkOh3AocOwP7Gc6XGH3L+nTqJfhFNts1DNbRXsopNIxVBtIz2pEhwnWSQrqPisDLmPHFBwRpGVn01u8w8IU1FKbAKC0J2nJXF8ozpInbjzDOmehqPWZG7yaKoq8cwAnp5XOk+IVO4l6tPxLxkExU5fT2ALuMq+sgOko=,iv:jaVyArpf6zMCFa6J9X1aQMGrmFq+W2CPZdWO6vVW68c=,tag:S+qF8/FkgHc4uW0e4ICmSQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:JwjmYg0qzoer+8/jv7KOfK/BxmpObHOn04GNsUFQd6hSFBWbQlRbODfluL54hnWQ4gsS9MHgwWh8nrLciBhye05kRQCqADB7Crm0CZYubNb65WzM/devh14jmerc3MYIp1M3VmDisML3x5IqhJkFMSqKM/VaNAC3f0otcVR8TTgZw/fJyvPlfqpPTjikGmo5xg++Yk7Cy8A1Dj70kYk10+EQjQ78jf4k/agLoaS+YvMmKXFk4EljtHg8Fe5H6Rn9nfMSSKGTZeAH3Riix2e+fIH+nWEZiPHC0UlPjJrB3SeBqqGRSUUJshVKHFIdKI1Tq2Ea+GDWrAyIRI0qO8e7WQ==,iv:mr39NxbZuYUDHDw0e+fJDshbt9R1hLgVqkUxUVX/+l0=,tag:u31xZRPfvH5j3Sw5U8/nqg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtVW5ENXlkUW83elc3ZnhE\nOEZ4MWIvQWZ4aXhuUmZGYXZNQnhJemsvOWlVCjIwNmFjS3pJOWEwRnhBOGNRamlM\ncXZid1RNYVBXOFZpMlpnU3ZnVE1scnMKLS0tIGFhWFZFd2JPTFNrV1VHSDVKVHRK\nbmhoeFh6YlUySU1ZRVhHbjZnM1IxMFkKo7aHkz2pEeV64m+OEkBZ2V1e+PUzoChu\nUc/Mnh37dNXSSJtg2KnocHdyzDGi1yQbA72xKTxx6QjYJWrAC/jurg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBReWRLb09reXUxS0YyMUVV\nQlR2bEdYY08rK0RoR2pIelpLVEtYL0pqNUVNCklRaDNoSjBWT20xbk1UdmNqWHNt\nR3JoSlVwODVMUVBzMERUNktHMVViZm8KLS0tIHc0SXRIS1IreDF1VCthb0FBR0Na\ncGFRWTROUHF3clFtTmY1azEwNUpPNzQKsgCND/BZcMTgBTAcnHunQcT6LG1jNrtO\n+W7Yx7bFtFBajWnRYiNpUZPibQJlv5SE9os47WDH1gs86xftgV+uyA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGbllNTlhxNVVkd01GSUNi\nR0lxQWZoRXI5NWxoTExRUnBSb2FPR1BONnpzCkxCN3pNOXVrTW5GR2J0WFhZUE9r\ncm1QTThINDVVVlZEalJiS1RXcHJRS28KLS0tIHV6aGh5QjB0M1VPWDVMT1k3cTBh\nNTNGY25NV0tWSUl3UHlJSExFQVdpVDQK3ARj8xhFRYU6oqYxNQ5+Ryza86ALNUwX\n7yK/8ATnquYC8/ZIYUbgTPxzsocmXX9lVT0+ktIALqjtG8PgNTlOow==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T16:04:51Z",
|
||||
"mac": "ENC[AES256_GCM,data:9X0dkAEGJiug16LDy/8//QOkHITTzt4zwTKiIU13tRgSRXRpt/7b27+3eDauscXDMamg5wbYo1YF0+VwLl21Ip4icnnwrZBHd6HWleP30HTgef8rmo21SstshnclZz6RH8SEGVDO/vjrMDChaZ3A2WD+nbcomw47DpK3cG3kBIk=,iv:G7CScSD5Z1Z9WMEMydyeGK/RD4W43xA0PlcpmTCxLOc=,tag:DZmby/xkxpinoJztZZRWwA==,type:str]",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:81JCCVaOeEYNyqTT3vXkFDDV1oSAlOrElGmvN+1Jy+U+dF6EaCSeFYT0U2i2BvUu9VAYEiY6NRAXwWUgZXeQXsI65eziB6d/8NKTr7UWb0eQxvhHbthgzxoCfdCvofZH49DdGuCEl8kU6hppSeM+wnDIIYvEgWljO4JQJr4/qnQRegMqJ33564+ZvhiYqrbfojcBYpTjVBzh+4aitqZEAYoFx4xhFAtdBfmAA1W6FwDvZLrX09bbdFT5jOqnSSkPunvTHiyWtyAfLgaNzG656F1U+3eRDv4eZQYOaLXXo9H4BaBMC5jyJFV1rDgl5s3diCuQggSyDbWUnwSS3Prcixj3dkrF8h8heNk/LDV5lGBtGEV3BQsEAlCPl01PIe3O5McfsRaR8IqwD7wQQ8ULyA56fHHAYJR1xe9bNGgdLryhqLDBFWi06Ztidi1MQXFLnNFJxoIhvANIAmt//TBWimkHnXidkg==,iv:WNChOcSG+QiZOMUi8jpb2gmOCLonM3zK7vcZCrXt4Yg=,tag:zq6Zvf3xu4kofaCechUpcg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxMzVHK0tOazNoazU0RWZF\nT1h2N01NNktHRjVMQ0pEOWR4RDIxNVlQTWlZCkV2a2pQS0o0VFZWN1lyaklLWllp\nZXJvZEt6Zng1Q09xQTFkNnVtTDNNVDQKLS0tIFhnNW1sY09ZcWpVcGdmM1hrK0o3\nZ3VscjBXMVFEa0xtbVlZM0lMN1dHS2sKcYtCKhw6D1ax3Isf5Vk93cDteUEjx79j\n1fheqgOjytY3W9t8U2NOEpUP1hT7zWYswnaq+agm8nfEjf1fbwR9MQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4K3VSL1NrSm9rMm5WNTk1\nVEpudHhJUkJUeWxieEtKSUdCL2c1YkNHWVdNCk9EeGNoWHdLOVNUR2dRY2YrMVlJ\nblliN08yQW1xdXN5VTRjbm9vZUxZaDgKLS0tICtLaldjemJHSkZzY2R1MHlySEsy\ncXJ6UGdZMi9hTnNjRHRTVW5UMlNMQkkKWhLTte9gTpppGCmA0lf/FEYu5b/ZaGYT\njn9j9qj3Lets0gNj9qBVaHUkCoJ2TAGORdkhnLEdY/Tm/J1w3XfjNQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age16y0vfts5v2k20e2rj23qa5lc5gm96gm64uwsqsr0y688xl0ne46qcfr0sm"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-30T09:34:38Z",
|
||||
"mac": "ENC[AES256_GCM,data:oC5OPZyZ4kEh0A8Mmwoi5oZ3+fBNvEDN3A+o3n099RdPREn6mcH5QC+XD9BXvWVxp9BzQEwRBmExlyZBEoRuwrB6DQy5Fn8TneEjQoCvZrudpbXIB66/EvORk1utpUsbAov3wn9A8tXUDAUJaRsY93/Pe1mTc8KzIOGv/QTMR9w=,iv:+p87aL7dKsbyAfCWxc0pecVB36yUUa+ltfzQZc4oovk=,tag:tNRRC056bqMToaHz1UrqQQ==,type:str]",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:6Is5g/gqFdQ9aTE6dpe80g7kSgDbhRr3yIwffv6tT5CLqsQLA6i+V5XPAqqhH0zbpXnT7k5Rc2tA2880MDa0eVoI+x7qDnhadfLKVgErWZNwQ4Nz9ukovf+lDfoKXn9AWNFxbiBckpCoxNOms3zMv6kqf1gbEwV7eRIoiq/i2xypke4nOJSmRH22dqFUldfQOOUL1D0H6RFQpNK+5O/Okb7URxIzdUgmq6/9zIsZTOlX5j1V7kzpx8U4P+YPyoPoRSLTOxpK/5gh+Gx1BQbXVTf8z5lm42tJxuQAt5kXqu0Tu8aXZzUUcMKf1oGVbo4TwRaQN+9tFgkhegVMSS/l2don3X3vWPimBcYHTfXfvvCh3krH3hoSmej8es2jCG8AesObDEmCyMNu9hdqhGGdCWspakrXVEuefCJ2Tq8fJfOn5XQhh4AX1wAf04Us0FJO4NjByNrgW8Bo1344wocAStxBxuD3rw==,iv:kkk5muigMC6iTIiTHKwXFreCuJGl7CWmwp1W7ILmq7U=,tag:W49PPiOISdFjk54wroD8ZQ==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQN0NWRllWaEtPdTdlOHFz\nUXJBbG1IRXlPbnNzMEJhT0FuazJLYVZHWUdFCnJXdUtvV2RvUU05eDBaaVdxSlBw\nUVh1azM0T2dFZlpMMFhsU2tWbm4vSVUKLS0tIGNlUXMrbUlDYWpiRTQzV2svcXJH\nd1RIcmpGbkYvSlZwYkwxanBZajVTMUEKt4HCEEPjsSDKvp6XuSSrQjVXFQLQHwk0\n1Fi/HrkhhdkIO1f4DyTOOznvK5bc+Z4JKT5lrOkKQln92uAho2ECiw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBock0vOGVLMC9HZ0NYckxr\nZWRwK0NhbkVaV1FVVndDNmRoNWhGVXlUOEdjCk50NFVtdmdxbEFuZE92YU1IUDlN\nbVJ6QnN1TEk5c1VmWDQwNXJReFdCSXMKLS0tIEl6TW1iV1NvTk5OeUkyOTRtT0ZZ\nR3NsV1lwa00rdHdTRUkwbzdrc1VaVkkK2ioVnzacNrQD6cpNOomKz9WfRRq+B3oK\nnabokq5rEOoNsSFNip5TBeDjo/34kOTXZCKXpFYwmBSoHCrkdFHvYg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1tm3zj5lp3elw2j832az4nj9xxmhqd66ag3cqcv3vskvmd7qdmqmsdpdcn0"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-30T09:34:40Z",
|
||||
"mac": "ENC[AES256_GCM,data:QOvDbiPiQBPYBGg3BzJNZzLBv//UccDfOnoOVqnpdskOpPw88uQOcpK0mcF/unW8o+B0AJe4wzr8/XhlhtlLSRi6buES2uZr8pjgCqrgFMCUX8WP+4rxiSq9DkZtmLO1XBtBtjJy37PgFr8bwUw1p8fNdhnOEJBYR75MDC8JG38=,iv:YCCfUyncprAkIbvxWwMGGZZywNHLTrr5bYjBBdQp2OI=,tag:mHyHw11ok4jm79iYn6L74Q==,type:str]",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:VpxnzrdX5FY2gfDcZpqg1f2wqL6mAawcrdE5A9A3fQOKr4BWXmCT/hjQEtt8qG+yVOTApdG+vF8SKDj1AKZ25KsZ253WS7W5C4j8oiT/xHo0CtvVani4fZqN60lB/9381h7aUZ+lcBMPaQcNn35zoQqYthuCGrC9PH1qQgD2VVHr0v8J6kceXKp0KTJldtDsQxky6ROC9Zyc44wwkrFPgPss4/yGaRWWqiojDvK5fK8238hmR8HyfD6Tf5KhgbqCsSGS2g6yt1muUhmegaZwtpi/KpoqBmf1XFaObuHGRPKvblq4Fa0x/sSjpPogBZyNuvRKIwRhla0FkKOH1bX4gC9bzOlgiUUfVcRJT6Nr9jocYuhIv9wvIT4TujGbgHyorYaDSQLFPJoJEUa3fgpGq+AC6p1DJJE3b/wqUSngNbdOtAPE/imaEuprOfnbS7cRx+Ti/Yu+ZYZuAlDtLkSVdXi1hQ4fLQ==,iv:rq/BzePXa/w/Gqewz8JfM/NdU+x4ShiH1OuQT7wRyCQ=,tag:IcO3ddoj4M57CkndHEmNkQ==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRdWFEUlRJMlZId2Yvc29F\nNnkwREkwQkllY25vZnRjV3Nob3BvNE1Zd21NCm56UVg5YUFGbEc2ak5MakxDbCs2\nTFVsRFhjVlRwMXdUbGpMKzQ1N2hBUUEKLS0tIEhmdm9hZEJqUEhRN0dsSk80bDY2\nY1JpajNOVFpEejhibjdKZlNHVjE1bGcKycyDfEBZ1WYc4EfAK2y5x/nKRqq7mnb4\nDlpR1Som4bSt7+B+OCZa48mC0Zv05HbP8PnoZdCe83swGNljvRONcQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0SGFVbXYycTE3amR3ZVhi\nVFJHVVhWNWVQQlJKZUJvZ2p4ditqdnYrWnd3ClZaaUVLUXFKaWlDdDQ4QW5SQUZY\nbG05c0RGNnZUenI1QTBlemVjT0szRncKLS0tIHBWTmNpd2FjWHc3MmR3cWt4SCtH\nWS8xZnVON1lGZVc4YzZFRmRBamdSVlUKdlQTlwiExTUVsiY05MXFG2/IQt1bZwKU\nlFDnuy6YCQzawBLQZuSUAp7WSGedupexlZKQKNUzPf+dis26XEYZoA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T11:53:06Z",
|
||||
"mac": "ENC[AES256_GCM,data:PMsPSffQkxRmqT6HwFezIoT4WKJ9WDlAcflWynsFLtzbqVTREN0YGOJgMwLd0yBR2sbGBWogG5xAuKjO16aiWUVpCFPRvP97um351679ZoXIlrOL+GlFn4NKwoLH9/eCp8BteB9mv5huhEEN631lqCr+jtYO6yS0ULk6QS4+7/c=,iv:oYWV4J1JUuGryvqntVXkj/HOzRDRr9E2C1RKNvr2bT4=,tag:e4p+I40y1LrgdlK9tLRicA==,type:str]",
|
||||
"unencrypted_suffix": "_unencrypted",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:Ea5AGwloFMyRgmQhJaDNTW19mpDvx0+dSc3ibENEXEniEIBV4Wv135mAwE6jQPbN8wF6f5ki3B5mDXHOMuhDQEqUXg6jcs4uEm2nwz7wAKYXEX4T8p0gUj7FPISiSQrw8O3jwzt4qF7AVcPsNhMtZLgrx9ssMtVmGct5do+E61Y7dFe0XbpJLynfzeiMKYvaBngTbHwQjv1mn9AMpX98apsp3tyzg8avLDR+WXRcMusds2mdov0jCoKuWPLG/srhMwjKOp1Z1Tu+aZCr9lHTT76mytpGIWGSbrP5l3UTp3oPLmy87Kwi/FJStZYdYikMnvmSo5qovePiYsLKVD74dS+d3oFGARyW4TILd9OQdXEjzdDRak5HcPv2/HhR638kvukVMdEdH0hpeJcqcV0h+BoLyIHe1e037iF1ZkCjuXE+A8LtlzRR0wqP202uzBIyg21ca4X5bRiHkkAOX4P3riaEcMgKfw==,iv:IXq4sHHsjnK6maVd3RRVZzhnxx1hOnxfsvYX8IKLn+4=,tag:jRZinAVLH8nGVf3gE4aSKA==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByOTkrVVd0TXlBRzdkTktO\nalU3WlVOb2ltc1k5RG5sRE9EN2M2WUlnQVRnClFGZFYvSHdpVlhtNXExSXZMZnU3\ncnl6aHRzU3k2V3R3dU9RVDA3VWp3VkkKLS0tIHcwREM1dDREK0h0UFp0bU5JTW9Q\ncWdyanNMVjY0SnZUWUl3Y2R2SXNyc1UKI1FPIE66to19oQK2TwM5B4snGOVMLFfx\nl6JMEilDVSgy2RR/tSiovmo4NwOsv0hmmF1t34547rbZb+dB5KrMEQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnbWp2UWphSnFibnA4N0ZV\nRHlNUnBNYUNzMzNZakFVc3JXT1d0R3Yyakg0CkowMFlOcEhPcHE5dXd4ZUd3cDAx\nbjQ2T1NqejNRMnFvbTdNRGFGdW1pM2sKLS0tIDhGb1JBQ0RFQ3NxbmpkdXg2WVF6\nS25uZW1hMVo4Q1JGdGNQWWRyWE84b0EK0FmFZB6sN7uZynhzYU932x461zSZIUWU\n8oyDEKNoUuPqpWK8RktgjWlKHtB0oLXC+SLAOJEhtnhWF+GSqloGfw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T11:53:32Z",
|
||||
"mac": "ENC[AES256_GCM,data:EhWEEIamG62xHI6SqO4tzdK6gcEXxUU/UGcF25m+X11kUVXzHd6yLcfiM2DvqcwGLmwmOOf3SpFLABlQUzTka4QRzTMFIAJi7x1rzQV4J0YIGITHk9rgvy0V50TI0loONBb2du+vDt8IlTBNvF9WMbNCki+fHPAjIemyoRBdubo=,iv:wfsx0H4fD3L7WeGzz57SzZtajBr/xTRDljSpz22DD7Y=,tag:naDyGH0WTKkqgd954Ya8jA==,type:str]",
|
||||
"unencrypted_suffix": "_unencrypted",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:z2bazlADvtWkGcY4an7NgZgIDSDq8KmYpSq8tcBleW/33HlElMNhdmje8m8hd7WxgrVvCQDdlAfodBLj2mjbgSwBptlPX2zbwcmJdm0g0Ope+YyQZucIio7NnBaYa+OK8meWqmxO2WEeOk+hnNfNPbt8MkzF/FMMbP8xv9wSTKP9CDdmO7s8rdg8G1JxVfn9dyaSDUTuA2JGNtbh+osd4CtNCV/bc84MjXDZTgTfT0W/uv8VH3iPNT7BhQbU9FIY4dsFRYBf758VtJ+3mTBiQkR38IevJBvhFB7Wiqn118LSBgjdTKJnBwmGo6lMdo0LRYHEgUnb2ejhWMNie19x+LEAErDki0RBmjc2BbnOF908kUlW2fUPo5fz/hlfqlhDLS1uOxESKhfnKt7s5qzWiMX/nohpknVfyMbmu9j/M571z9sgFnJAylpW3NLp53lX8N9mINwRGXsyL/ijbNE=,iv:M/MNquAGN+lIIyVVvFRgKR6DKAhLclc/OY1HV5Xwoog=,tag:eEuURMErSqsoh+l9ZaHY9Q==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqZlUxRG0zR3IzMlpDL3Vl\nNmRWQXUvc1NDek5wbWtKVEFTcTlxL3ZoWldNCmJVMkFtbDkxN1FGQ2VLTlhBUW5Q\nZ3VzL0UydUQ2YnlUMmtqa1Z0d0FSVFUKLS0tIGJFelc4Rm84T3ZHSGZDdnNUbUFM\nRXZHV3N3aVlSOXhUZk04UFRMWU5TbFUKhPU80PVYuDFUCxu1CA8+W8bqkr+Ne2fh\n+nBUPJbGxfN9TyD9tUC77AMbcL1R2L5x+SSAh0bEgSWE24/RjsuVKw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3M1o3S2FibHV6N2tIOFEx\nRytJRDFIUU05azNDbWlpaDdFQXFRWlBUNnlFCnBwNVB0SEVwL3NWNS9aeG4xSUI1\nV3NOcUFxUUhaODYyWXdFMEhWeFpvancKLS0tIHY4NHFQRGJwUURQbWZ2cGlSdm9s\nOFJodGZkekk3UnBRRUwxb2YrQ1ZPcU0K0tCapb1hfVHFSNpmESXexYa5k9OE9Tha\n51QpU4mZHKGrnWPK/kyj7rHiz95TLsmwUdA8Q2hOiYNSxEaVBjYDQw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkTmNrbUFoVFVGcytzQkk1\nMUxDdmJGL0xDcUNZaDFRQnUwNXoyMU90SEVvCi9EaTlVOGlMeUZtQzRJS1J2TTJY\neWdkMGNFMWZzQXVKOU5KcXVvalk1ekkKLS0tIDN2MlRQcG5aQnN3bUMrSDdvc09X\nM29hQ0pJRzk1amRHaS9mRlhrb2FMZG8KEWkSSP+MqGRU75qo7ctOCL7qHhyCM3l4\nL+ga1XOKxRsQkKQPozCi5Bm+k28W9hIDaC34Rw9difxICM9Z1rAbOA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1TUtxVjJ2STFpU3RNdWpJ\nVjcwUHVORVN6L1pKVC9TeWt4cTNoR2pPWVRBCkR4QVJ1bHFUa2Z5aWw1bG0vQWVy\ncjNGRDl5REZzbzdZWTFMRkU1eHFTc1kKLS0tIHNLVDFiRkRKTHhrSEx2S0J1R3cx\nSkdEQWJya1ptTTRqUjJZT3FFaFhkd0kKgecHrnzW9+Eb+b7c0z1yR+Y0Czsr9Kjh\nx1UUOfleHntJUCs8oYcOogKknEnhBJJoJ5oe/gWruRSwle1fs3cBqQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-27T21:22:58Z",
|
||||
"mac": "ENC[AES256_GCM,data:VG2ygV4X6yMxGlJgq3sN4GAzgVqiD06noyMxB2yb/FCmiFVYZAH/9LqF//G7MTInfIjFrTDyoHAny+m/ztUrJkHXI1Fzg0U8R/zYyQ6wj/GguL0gyhA70uriQhvHsRHefHPa7Km61Blo9cME6CBJWejtRvh1IMw9itAioVrSIZE=,iv:zjxNG6IYxkcNqLk08NADoDiIbS6at86y136SBzadW1U=,tag:sj4ZRR7ixU8h7aOUJmTCYg==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
+29
-21
@@ -1,35 +1,43 @@
|
||||
beszel-token: ENC[AES256_GCM,data:meuzUP/6wCssJDVTgbC0XwiLZPMGyDl55HEIiON9xOXCD9k6,iv:TDqWcp+8Mxd8wN09r5otQRQXq3XTeQphaTWxvvuLTAs=,tag:cRPZQGlwB/dTguBAheWPQg==,type:str]
|
||||
cache-priv-key: ENC[AES256_GCM,data:6vQKIf7eS0WNL2Eptoi4VWr18SRMZfN/H/aFUUtXdMYQY5LLyBp2EHRKqZcGFuh1nZhUdAxUztq/CVXx+QFxKW+ElHxCxUSp0QqI1fdSkBkKZb8hlit5SoX9JtLzZGg0HBNM3nJu,iv:0J+xmrPJhInHhFR/c41ACjuTfaIoMkQFSfbL2KkgFa8=,tag:f4s9Szs5oprVVRSyXaX48A==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPWE1HTUhiSUp5ZEUwWEpI
|
||||
bGpkZlBIMUo5ZlYrQ09SN3Q1a0ZkQ0ZnOEhVCnJPNEZQenVWWGZiODlzQzNEc1Zq
|
||||
c3l4OWZJTElJc2Y2UE15OGtEUzhyY1EKLS0tIHNJUStyWnlQWjZBbEZjQ3UwdUpz
|
||||
ZndoUDR6bisrNGJCUHk3TGI4bTZaMFUK87fFsm9ne9s+PK2pcwtrDjqyGBss2r2E
|
||||
8lhqoeiKZ2j96z8kP/7ChzovwTCmqdcmAQuyNQD+ZAFijseipSvfbQ==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwMklZTFJuR3NYbkFvV0l0
|
||||
eUhMWU4vMHpnN1NKVThuVVdiOFpkZW9rTjBVCjJabWkvOFpOSm1hdEdlZTYxc3BP
|
||||
WkRURDFEQzMvRFlka1VnaU9zRjhiSEkKLS0tIHZXRG9GaE5iVjg3M3I0SzhtN0JP
|
||||
UlJVUzRzN3NEZWxXZHJ6RW1oYWwxS2MKonnhq7YDg4v93PZtoaLANDy8mdRCenjo
|
||||
FjRUzozMLpgWBll4DwRWikejsrRofRBwlcsiIdrBr90f8Lr9pHdQ/Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUYi9SRFFGV3Z6cFd2Znk5
|
||||
b2FLbWtzTllJMDBUaGk0NTViOTNBa2hQclVZClZHKzNhbGVjQUJhWkFWdTFBMG5a
|
||||
cUFJdUdyVG5HQXJRRnJId3hqRTN2cXMKLS0tIEFMRjh3WE1ON0U2TTNTZ3hxMTR4
|
||||
ZGRlemlIbDZKeExmVHROc3Eyak5DdzQKaLwIVDi6BN4cxpVxJoqTYvJETPOp4thc
|
||||
l9uVMvIGuEsEZgDsvShw1dYLljd+uGy/A+dXbcxIUCP/mmPkwmd1Pw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsWDJQYzM5Z1JRT3FlVkJy
|
||||
TkVaSXdzanYwbnpOM1d2RFh0c2NaSUFmdW1ZCit4SVFxSS9HNXhVM2gveERGVS81
|
||||
NVFQaUV2RGR5Q3kyUTQ5eEpDVXJVMWcKLS0tIHdHSy9WM0o3Q0o1THhXZW11K2Vp
|
||||
NTNtSGM2UDFuWDBEdS9tbTY1ZWt6UlUK8z5qoi0kGn0ES3m9khummuU51rkR0Sb9
|
||||
TWT92+BWvPdNrAsDFjv0fgpUKyTMzN72EzHZKAJCIM3crUG9I0tX2g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age120le4a5l8dh3lyfgvmj3d9ksmej6ajs5mer5y7r0vfg3x9fn69dqf8xgzu
|
||||
recipient: age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOWovSW9DeFpxL0VDUDQ3
|
||||
SHUwTzJVZUtPV01ZRkdCUXZGL2lTRCtCNFNnCjBSNExqRW5mTEN5SFVucHJHSzZt
|
||||
cDlNc3BjY3M1c1k1Z2tkVEg4R1pacGsKLS0tIEFWbHNKZW0vbVh1Y2VhQW93OUwx
|
||||
MWV0eW9sOXdQd0l2ZjlWOEVVc1dwcTgK2s4p9xoNkawH2OkGsl80bNIo3ad5vn4W
|
||||
Z2w+jwppSoUmbQnD3WFbLmSSxmuobmU8HILwElv6SZu+KE3aspF6XA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpY3o4SEpwZEdFQmVnOTVV
|
||||
d2NqL0VudHM4VjdDK1N4dWdlS0lGR1V6a0c0CmJUTHlsbEMwaEU0Kzdaa21lRFRm
|
||||
RTRnTUlGUG9GQVB4U2pzTHdRY09udkEKLS0tIGlFQW9Wd0N5WFg2WUpCQzhWUm5v
|
||||
aG1VVWV5ajBmc2o4ckgyQWpWaFVxVmcKisAw40bGQBRH+u6uNygfYpfb7iEgfEHj
|
||||
E+g9n2WeVH8kUzD2O1o6VAu4m/SVuI4+IQ77j9GEWmlI/wgp8wKXgQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xjst4frdh0th6q8m7p7u9g5af7ty5jqeum0p6z8a52a9q7st7ewqw8yl9j
|
||||
lastmodified: "2026-07-19T23:30:21Z"
|
||||
mac: ENC[AES256_GCM,data:kLGE2xawQT7mx+sfw68hmGk5nCEGiEjZrqTEl9B1dtQmTrMwmoVr/1RISi4LfJrwxy31mDgff4lcIL4wIJuM373uk3X8j4RNyYQNTfKEkORT6r8NHeepNs267O77pKGd7OmcM4MT/BqOnB8ELS7Wlf2ect7CAlvUUVyc8icxgZE=,iv:EYLDsHYHZ1XOQXafOTqHHWpk/OBNq/R6IJnOBYV33E4=,tag:thxrCPC5oGvDjhK7Dz87YA==,type:str]
|
||||
recipient: age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1elpNZXVJbmJWSU85aUV6
|
||||
NG9YTWVBeWxiRHUveStTQnl4NC8rT2VNdHpNCldxZVJYNVhUR2V3Vk41VnJxenVT
|
||||
WS9rRWlKcGpVdVJPakkwUTY2a2xQTlkKLS0tIFhFVTRucFNuS0pMK0FNRk1ndnFO
|
||||
SlVvakczaktUa2VLY3RLYUdVRzFyamcKIhctg0mbYL7OE08dRwj5wMu2x+O8/BMu
|
||||
IqA477+noQ/Rjrszb2hEvxID7keogcDUWMWQzQvdMc22+3mvAr9qIg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68
|
||||
lastmodified: "2026-07-29T01:59:11Z"
|
||||
mac: ENC[AES256_GCM,data:/nbcfause6G6F8IvMoyPZtkWS1XRLAivhwTFu6y5P0Mm0eCcO6M7/rgioN9dngKzPXrCUl3Dx/EvhrrWKe2/Saq9WEOFgvS2V05pTRbQgYjuugVzW2paPq1fgmoDYNjHz2yFYWAovbIFtjVxMR9tmcASMTe6r/FocvMXbFCJh7Y=,iv:n3KoaPPtDhWK8mxJNJRk7WPVUUNR59nZSA8JyysWNDc=,tag:tYhkNXsj2ohVjHkvxife2w==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.1
|
||||
version: 3.13.3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:apiijrtrqd77CTizITg0R35BfCi8PBnufpxIyC+hLYqwoBzP//3z/yjFyHPLG98m/c/qywoi3Kn+zsaTT7MjP++9OMhhX94YKlSHV1/cHB76OkwsNc+ClqWxl6vpaFX29Qvh3gFX9c/NR3xvYQutYwrIrQ9NR+t/M52IMC8hvtR1LQy0ak3VIuXJlSnG2r4kF2Ym1iP7phjuq39Gd245Axzw8OB7yGvOjNxSdTPxW/qL0fMlzNcMrjr9hw15WlqnZfWPOsB1+gZjHXpGfPD5BCbAAMoTRJd75vhKKXP/ERhIffewuuH2x/QHfSFvXVB3QyhBQMxd2b8QEEE5cjvcExOST3tkj6QARkzoUpRT7AE3jhl3XZ0uA2qu9SwyrSvbr0tBRKxCdK0g2E2/hqwcK/Tck5GB1eKb4aN+UkqxOblNDH+B1RfDoyNAuN+KEg==,iv:0p+ScrKpP4kQvO52gBAlwAis6oAzZ0EHFnU74hYPrn4=,tag:ON7qOjztF52xsJWAou7ogg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlYXB3cVAzb2xEZ2pGa1RJ\nbS9ZVTc4Ums5eUZJUjEvd1g1aGVyNUNramowCnptZXFOZVB3MFRFcUtzSXBEZk1B\neEtKcDdLS0h0b1h3VjRjRXRvV3V5V3MKLS0tIHBDemkyUnV6ZXhTeE5VOVVOMlky\nWWMzVGVzZlAxMjZYUGpQUCs5QmxiYkkKcuBshCgWX4TwfVlQ5lHikzvwWdLEXWD1\n/uSiy0J6yMSiu8u6cg2SxeFrlKJ3j47dDlT6WHCxS0PfeEA0bJb3LA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGa2VTc2NWdkFRNUJxelVR\nWFk4RWxoelYzNHo1UFVhU2ZkLzEySlRWN2xNCmNmcmJod2crL3NMRlVsSmpmVkU2\nMjlXMktjc3piUVNhUXlTdnVGTWJkUTQKLS0tIGQrMUxrNDlNTkRCSUtFWkxRdXgw\nRlV4ZmtYSGhPQU84eWtiQXVqTmxUK3cKk5fn72UZPH68t5ZappfAhZJwzpLkfKmT\ny9TbUPIr4Pbrexau6YiH43QIbDQFdwYPfkBjGkd57zCg8AVo1+MBRw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1adur9g330gua4l6ndk8cqjg35qc8yxwgme6wrl2hpylcc7vxm38q05ejuy"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArNWkwQXFWT1RpaDNvbzYy\nQWc1aHhmNHFEUXVsQjZqb0EzM0wrV0dwN1hnCjFsUFJiT3REK05uSGRWTEw2SFE4\nY1FleE1XVjhBbndiMmZxTWNTYmhYeVEKLS0tIEJiZzJvS3BsYzB3cHIxa2k5N1Ro\nenFFZDVaODNnVGdBZTBOYWJwRjQzc1kKlXJgee8wTSN4Beq4P0t9cYbk0BWHCseQ\nyaWpiPT9aZBEGLFmuEd3zKABc8lrilX/ySTmOG49vRg6CPmr7cT0Wg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1rrxqea6q6pn39sw8y5te63h2py8jgjl9v0jyper86w3ggtn67upqg3ah39"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEa0JYenFMNmNzWnVmcXdz\ndG90ZUZ0WWlIU0FCZG9OaWpBM3ZDWnFhZFhNCjhuV1FTOTJ2WVJGa2RuNVV2MjR0\naVNXa3diaWxWUlJtdkNOQXZ2R2NsQkUKLS0tIDcyYXh3N3B2QmNiK3dzemFFMGV1\nNTZpTk5yNGV5YVo3cGswK0NLWFQxQlEKIe0N5OxooWXzt1cUViBmjihmGEe3G6/f\nkz2/IscnG78ZvNgYKjdoG1jlsyje/3zI4C8aWXLq2DnIyxUyAhPgsQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2b0NVVm9QNm9waUUzcXBi\nWnZWU0JETjZRZmprZVRUL1h6ZHB4cXkrdm5rCm9GZ0VnTXB3S1BYSmlGWFJVcDhJ\naUl3RjR0ak9BRmQvVk1GRnQxNmtYM00KLS0tIFlTU1p2OHhWUGlOWngwbE56NEhF\nRW5QSkVVUWZpdDZXWEIxZ1BkbzVwclEK2P25nBgf8255vaKW/+T97aNTecRgNjLu\nedIUiPdXbFATCe3v/YRo6sqzFwIsvM6Bl9yHh/SXo6Ftc7eWZZd8zQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T01:44:24Z",
|
||||
"mac": "ENC[AES256_GCM,data:J0D8bEs5mHLraLS6TvYuCgfiNU1xKM2Yfb5Y0f/q/4wM4LzXufNzv3+SWDHumTe328U8UnNXLqjNHEKL0bZi0coxpU5hVM+BvPcmqD72vscETzbQ2hnU05sfW+XjfZhcN8/ke0bpLt7nP0crD5hsZv3esV1E2UWvzjEiYtWzFHY=,iv:lcmXYG2H469UKBYDndWKMO+GP0mSGLztenm+kBaUdYI=,tag:ujiXbLM9CUsuoFwQQWI84Q==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:Q++XWxg9tvY7ugT8+8FWCC5jgOfQ1+LYLsnqN0/WGxTf5XT2VmoXvszcE/ow2BUOBG3qBTXS3OHYFPwmj1GgaBHB8rpdXX6+LveSBE2gmx1VR+NUDTxy+0/DBq411MK9n/J9eIYcybdFIE11biFSAob9EgfxBF5roCDXIPDPyVCSe6LyhNvqYPnGQsfHCbejSewLTcRQEiguP9BX96CpMPIpmaB9fHN25t5RWCgMI7MtacrRxRsyKg44+2FZstXdZrp2Wv9u86BxqdAFqtZE8qpPeGdrdzluZx9jhnw0wZPzHdKg5wS7/UrLCb0UxIQiDxDMDuuBuLGkKXfAhb6SGZU41wWAWYk548iGRUaG+79BO4HhRZNObOFvfsjpMXEfcH/Vbv/wHVY4OpJUPe/ZWK4wpL9YrY4nT0t62HH7MirOy1uhwLE50h2+6dHKj6ur5G7thkSqgzVWXQ==,iv:zaRBwS+gfXLhH30havn6Q2+oPWuLV3qBfbOj00kewlQ=,tag:Vr5iEQ2u+9YNahryhgzwSw==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArTGJnaVhleUtsWnlITE1s\nNmRCaW1QWTFNSG5LRFdmb1lzRlV5NWl3YTBNCmF6eUN6RkJBZzc0MmJJa0dKTW01\ncVh4K1VLR2lURUpKQXpxNGpQNnpSUlUKLS0tIFJONnJFWkNCR3pqalRsUW9POVBj\nQ0pFQ3ltKzBETTVXTW5sV1ppWTFJc1kKzxUboNZO+Nwn2eTWy11VP9w1pRswCHaJ\nE2dYU0oUOClVzc0oSuIJxraG6TPj1N4WGC24gS+UmpkmSuCiOeZBsw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDSmxNR1I3dUFKT0xUVG9h\nWkRIakNVeWRQOEN3blNRVjZlWHF4K2NRa0hBClRiOXlmTTJ4T2JTUEw2c1l0R2N2\nMnRwdDA5bEZlQWJRTm9vUmNKclBSU1EKLS0tIFcyeDFjbTZyVEVDUjN1VzU1VHly\nNWNDMW9rTXY2bHNWYVR0SmtMckovUzQKhTWr6yFVW9am3okCiIswwqR5+/p9OLmB\nWCgPtwoFaBt1RjUXPK4/eS4LlucR2K6V/mNMn4xVsnkIl193U9632g==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age16j42pdc5dr6wnj7xayhkqdj2rny9u68fcqejs50hqq42scssh4gsnrrnlt"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2ME9CbmVGcENRWksyL0pW\nOEJyUGhpbGMwWlBhVXBSeXQ2MW1EWnFuR0E4CnMwU0pjdk1YMzF5ZEhTVFlBaHZq\nam94UWVGbjhZSEx6VHBmem9JRWgwYzAKLS0tIHFJZWRyRjRHNzhXdDJSYWN3bDlR\nYVp3eGJWWkh3Y09ZWElyclZQN1ZSVFkKjR32//EcFAdMjVlNgky5zvVkwXwEN68D\nrkTuHKjiO5aV7yAQGPkdNw0UM0oRGF0u4YF3oOUcZfSvnKgDeoi2Zw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1ug787sgt6st6k82fgkrug2lzltw4qsukrrqqs3w27ewwqj8rg4hsxcmylz"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T00:27:46Z",
|
||||
"mac": "ENC[AES256_GCM,data:AbJIHYcFpeanQsJ3x7RPL9Yjlg5BJgkepKax0fL9L/PpA03Antab93iUNG95Mp6k/duovp8Jm445lbuppDZq1dh9ij/deBa8GbzJ50wwEe9zMc3EwRKScpqZEhRPF7KlJsIjsHJyd8NkcI5ji49XkHb4Ae1//8zG5HpVgy+3b04=,iv:uxQCbMwMIfP5S1dbsvIx3F79YWEguxwox8T0YZvUBdc=,tag:3utjmBGDmPc8q4JjaXvCkA==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
beszel-token: ENC[AES256_GCM,data:cbQOXhLzNk4g9d6hvm2DH7Q5ApTPCTzsW2txflDT2dD/UPIE,iv:V19MI1GEo5/0205Hrt7JImfkjduFiZ7f9aIkDVaI8mU=,tag:WCArgdrnIOudVe/Tw+oxRw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaYU9HR0lETDhkYXR5NWlj
|
||||
L0diSE5JQU9KWjRoU3hrVjdqZ2tPNUtOdFZvCjdUVGNFbDVYa3pVSC9ZWVNORER4
|
||||
QWozUlhoSEtjTk9IRlM3VkZoYlc1RTAKLS0tIDlHS01WVWlOMWFEQU5GTVRLZVhV
|
||||
VTcwekhrRHB3SlVYT2MzOW5GbE52dkkKKCWehPhpdGapdyzpll20NJUcZwvW/7X8
|
||||
KQ1EqAgI2fewnbwuIDYCleN0b0SLJNUeSV/tFKDDoTMnHWCdeD4ECg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4YndybFBTQ2p4SGZ4SDJs
|
||||
a2p0eFRQOVVWcGd2a25ESW9ESGx1RWw0Zno4CnhpVVh3cGI5UjY0YmFINFFPMTh4
|
||||
b1B5SjJ3NTNvUE1QUmJjVFozY1dYS2MKLS0tIC9HZGNpOFZhZGNFZGt5blJuZXVV
|
||||
SXpkRzV4d2ppV3ZQZSt1dmxYNGVFMUEKmSe9dkrmkND81Hw2/ATAmFvcmhk1tUC1
|
||||
LxxBw54IVHUqwYKgRYUYRNu+pykDT5OnFDPiskd49Xso99LY87PyiA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ll6hj5ggruetgjwjfnplpn5xtq35uhlcdflksx3xmnjm6s3uad9sz70jkf
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLd1FSSHVTSGdHWm8wMVQz
|
||||
d0dlOEsxeGwxdHU0eUlFSUxka0ZmcVpnOTM0CndYNUVjVy92QVhNY2orQmpSQjYy
|
||||
VS9KM0NUTXhuM0lCSDBZMWtISEdtWTgKLS0tIExTL2wvS3FEdVViUmRYZEFsR3R0
|
||||
YTJFM200RjF6MjNxOFA4eHRpWVhtRkUKOqBIT445HnPXrrH/qV6FIuAhAuJmSL6V
|
||||
+PQopM/m3PAnK5m5Mu3cfjYfDiB8+GWTABhljfT+GbcoK7CqWLehrw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ukpqxzl44mnjpy5r96sfuc5sqzm47u4k8ujjh5qdgy6jvl9uqgpspymqfk
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvVXF4a2NmaW15R2VXS3FP
|
||||
SDVPUGpUWTlIWjl2N2N2SXR1UlRkdllWNVRJClZDbVo4dUhrZytqZkMrYWtpOEZx
|
||||
c0dSZGViN04zQ1B1WEZEWm1QM1lsejQKLS0tIE0wa0k5Rm1xZmw3OHFESkVXc25j
|
||||
NlFpYTJSckQ4MVlZQ01reDlkaWY3TkEKsmQlreRhRAjVZ/q5x52FNATDF2sLhbHo
|
||||
djOZigZx2rs6shqQ6It/XRJ8CiPaXZBPOt529Gwmplu+hWlAU1+l2Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age15kh7akxlx7zn00tey79rq2g8lgs4j5y77rcnyfxrxap8ckfu0a9sqvtdhh
|
||||
lastmodified: "2026-07-19T02:30:40Z"
|
||||
mac: ENC[AES256_GCM,data:rKHZjU/MH08ASTlu32HZO9uWmsBYuMCEC6M8gwVhzuWvmablnP05tS2z13XfaWaCEUXk6kmGJKuU0zu5+IKVZgamCF6DAMtxQb6bVCaLsoAm/GSqWQ5VI9eHqgnSSdN/o3ul/33Rf8iBQo4aw8FFAmDVuNz8bfAn0QefFTj0ByI=,iv:JD2gtqRinOY77etg6PUmZNovkYl1Q3F6ZvRi4x7RznQ=,tag:/5IMpWKRVt+l1luCTQE0BA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.1
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:+/1AAVha+86abQIX8tebqPJ6BIcCCiTJgPe0OfFOwA2Mcx0NJdiPtQgyeo3G8fAolIQOFkk5reL+GUhlOz4iEbPOCWwFCckdH0tXiCiVcD35qQNMRurY8HmpM5FWFnyzBkKuyNZ8okPuo2+fA3NQh3gs94rpm2kBsfS18xvrlv9b8u1JvAxlH6GRMN1uUgFGmOXlpGAVjDUFiKUHN8tRSZpsxG0aKKPBZ82JdKgYfCiCQifS1366gIFrsjGriUC+P8wkadRnD1JE2ZAGSL7wJLaRmzA4LAMGXFGbitOsFxKxX3q8VWEXaaL+9h4rTe0WCrvmgDM9NUeVHpfRiJrLvQgrFZMUxuZF66vQVsLybjIWpYYJcjC6nfR0U3lMa9gjzOTMPxm8HdGKC53FhLM1HiPoe1a2Nno283fV0uaByMPmo257O6l1CXqb6KoZOGFccm7fKR/VnFAS1AInF+szI7/r+UcaTW6LwYeV3OwLE4a8b4OiqTLgCu6v,iv:kUlVXF4Yl5HGvoLwu9loiuuDVtz0kARRiUU3K+BPL2A=,tag:pVaGzQAX9Etoqc5XMGmNeg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6OUVUdkoxM1M5THlYZ1ky\nYWJ5RjhtRGoxTldwRkI2T1p6dHR4Y3gwR1ZrCjRDS1hTM01lZ0FSalBYQ1B0bENs\nUytXNmlrUkV4TmJkMUM1QW1ZaExIcGMKLS0tIHdHKzdEcUxvM0ZYUEp0a2d6SHp5\nYXQydy9uNG15V2FhUUd4NEFTMTNNMEUKkIzKEoYzoVs+nhnpkHFgDkQqrWykatND\ntsNxcr1SXSKeEW1m/QpXZnn/aW3zSQR09PqOHf7PYU47/AdkwrUaAg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByZENrck42dWJjR2hYUVBl\ncERtRjR6dTdING5nWXlBc3o3eXFhaSs5NkFRCk43QXFUeTBJR0U5dG9YTDRmQ0Yx\naEo2blUvUXNZY3dpbEZRTUx1elNzdkkKLS0tIHJwNjRNM3V6WktWZ1BlUUtLRTNI\nMTVEbCtYbllIbTdxTGozWUluS3pIRXMKZCruDIkD/JofdAHWgPuaaKTDsz408ZkY\n77mhO8J+kd03qwt6qhFC5KF1lyjhwEnqrOE195+R/8Yl7hA/DsL2ZQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1k7d2du5mejsmv5rzavm4xwgpthqvcfsehduquv28nzs53zppa3kqngfxq2"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxaC95RjYybzVlU2tYdHdU\naGJ4M2IvNkJwVlNIU0VCZEFUZk5VaksrVnpFCmJXS3luTG9WejR0Rk9DbHA3dngy\nRElFWjU1N2xuc3JaKzQ0L0U2Yktib1kKLS0tIFlrK3F0ZFh5Ync3ejVWMzRKTUx2\nZDhxdEMwa3B4TFZUcWdTdktDeGt1QUEKfgYnK2lW3cZuJGaw+bAKDipLuC4S5vK2\nxK2eJB5TP/xXrp0F3lx9sc2b1FOY9Vt9IQ7zVlBqJFkzJrcw8zYJJw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1zhfyuzlq40reuqlr34gf77852nhs3t6mqfzrqmas8z6sxk7tcfhsungrm0"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwOGFkSXNxTUIzLzR1Skk5\nMGZveHFDN3YwVmpwT3VTYkppWEU1aGVpdkhRCjZtRC92bVphd0ZFbzFxVzRUcGk4\nMUd1aWdEbFRaM01FT25JSVRoMTRsNUUKLS0tIDAybmcrYjFVWkFYaGY5TFZjcjFO\nYlZoQjJjN2lFNzd4RlJuSHFlRlNCOFEKgUIPnL2/OJgz9oMYt86/llHa7adTkhs6\n8yGYGV1wtU9aYtUMIR907SfYyZ6M4z8jH2wwzpQLbwQQMLgkejl1jg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1f7usptjx9rv4rxauasve200gxtdt9jkqhhdqstlf20wvlm7u75rsjfw50m"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-27T23:14:51Z",
|
||||
"mac": "ENC[AES256_GCM,data:CYhzR0Y29GXvWUBbb13s16v9hDQ4k4Xmd4FUpcyNtHe0L/IrDGkx1WHzAohNGZjGMNB9W3BgiEH85OoOo8r1F82El4/8s2prEJi8AARvQU3+2cmRjjhNCCYQWmJkoF/cZNpw6nVyWzZdfUpPvHjbuf6utlI3rtR0qiCpSo/32S0=,iv:XLKTU2Ute4jMkfRAbXiVGxqP9+fjSs6HwRv1JfTTe7w=,tag:oIqS/1HWQZ7mLWaO7GwLFA==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:QvViw/s1N7eIN3CoD27llEjriuSrohOou4Cv310nigcW8xMnA2SDN2id3H3AoKii1JlJ+qWpKn+gUmt5HOM0UlbeNe3os2RVwiX38O7eN9xFH9F5kA3TFs6Umqq3EoI586PwIVmB2LyxDnTeEEXVd7v5PFkBcfu7u8YIcNF7lpcj+6rOyHMB8uxPhrGep3yiKawFd9c9wWD0hlSSatV5tMHA1qmdK8VmDbCU/iuGwIoMzN1eZwGAXzG6LkCA63bUfdxU6yGuTboD+kN2Wbo+GZB0EACmiZoofl2wqlXuiw6qwTvlXkyauc9O5EG//PUkIECzDwiXcX+qSOM9DIBlZNth4ebhtic/PskyF09etL/gICz5YvV4ph5lyrWHq41KxljSU7QXOkGhzagruuMrYzhZb35wFH4Tie1ee2DXbGhreJr8V3Zse/zTMaD+iM57V8bvNcarTzOFXyKfp7Y=,iv:XBPhj2wT0k/yRCRHU4d+BQA/k00ZHWSKOucnZ5+PGys=,tag:Nh5MOxZIUXAYVZY5SZh/JA==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0aUtETXlBMmRlTC9vK3NF\nRW4zS1FrSmo1dDN0OGN4SmV5ODd5MTRVejFJCnpPQkM2MVJ5WFlYRW1NVjduV1hv\nUWUzY0hHV05LV1BROVZVUlM2NldsTlEKLS0tIHFsTzI2SVZzYUtJWTM0MmFiUlVQ\neHEvUXgzc1pxSU1OZFo0cXhSZDdGUVEKpLVfzQEnntluUGsblnkHZJ9Jezu8tFte\nxEoV96GVHxUca6TFWpTLMqdR7NtuQGCkx295W3i1tkp58DP1OzRTUA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZW5ybVBvTkxibWJMWGRL\nZ1lnWEkxZ3l6d0FWbmxMY2VsTVlzOHJJM3owClNYd3BlQi9pL1lIdzJmekJUVEVi\ncWhSK3ZkZEMyeTNoZlpTT0NMUXZFWVEKLS0tIHVkVlhmQXdRQktTK3J5dXZ4aTNt\ncUw3WCt5dXJhTkdUbVpmeWoxWkoxNnMKj4XtdwmgFVOiVsIJs2Du7QJ09A9tv/Lf\nkFOq8y4tlZe0nCwRjq43sVz7hdCTdQ0rsaWjBGY90LLkJbOA+f+Wrw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age16kqfmvz4e23hmdlqresnyw69ej604s320mmd49h4hm3fhqchtgyqrws0k2"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T01:44:30Z",
|
||||
"mac": "ENC[AES256_GCM,data:zR2WLWX7NaHA15gi4kX0jDvzUIe9jtz5bMCAggbPW+IXOEedPrddAHZ8OfPErVMfx8O1pJKkAKSzoPTAbEle54FisSLMHXp8fI0297MByJrF9pOsMFpVcDy/L4Q+pBzmB7aS9r7+u7KRVVTZT3QwG1rFWZaDs5dFTP80RhtCbWQ=,iv:ZzPoO+h7ebS+jsSH7tWMx6QK8umpa2/HFQmx9dnJN+Y=,tag:vG9+ifxw4HaBE6YsmOwXcg==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
+277
-116
@@ -1,182 +1,343 @@
|
||||
{
|
||||
# Network / domains
|
||||
lanDomain = "gitea.lan.ddnsgeek.com"; # Gitea/DDNS domain
|
||||
homeDomain = "sweet.home"; # base LAN domain for service subdomains (pve., docker.)
|
||||
tailnetDomain = "tail13f623.ts.net"; # Tailscale MagicDNS suffix
|
||||
lanCidr = "192.168.2.0/24"; # LAN subnet
|
||||
pxeServerIp = "192.168.2.247"; # pxe-boot host's LAN IP
|
||||
pbsIp = "192.168.2.108"; # Proxmox Backup Server LAN IP
|
||||
rec {
|
||||
# ── Gitea / flake remote ──────────────────────────────────────────────────
|
||||
|
||||
# External Gitea/DDNS domain — used only for the remote flake URL in
|
||||
# Switch-nix / Test-nix aliases (modules/common/configuration.nix).
|
||||
giteaDomain = "gitea.lan.ddnsgeek.com";
|
||||
|
||||
# Org/repo path within Gitea, combined with giteaDomain to form the
|
||||
# git+https:// URL used by Switch-nix / Test-nix.
|
||||
giteaRepoPath = "beatzaplenty/infrastructure";
|
||||
|
||||
# internal repo path to flake
|
||||
giteaRepoFlakePath = "nixos";
|
||||
|
||||
# ── Network ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Base LAN domain for service subdomains (pve., docker., nix-cache., …)
|
||||
homeDomain = "sweet.home";
|
||||
|
||||
# Tailscale MagicDNS suffix for this tailnet
|
||||
tailnetDomain = "tail13f623.ts.net";
|
||||
|
||||
lanCidr = "192.168.2.0/24";
|
||||
lanGateway = "192.168.2.254";
|
||||
lanPrefixLength = 24;
|
||||
|
||||
# NIC names inside guests — determined by the hypervisor/platform, not the OS.
|
||||
lxcLanInterface = "eth0"; # LAN NIC in LXC containers (Proxmox --net0 name=eth0)
|
||||
lxcStorageInterface = "eth1"; # storage-client NIC in LXC containers (vmbr2, --net1)
|
||||
vmLanInterface = "ens18"; # LAN NIC in Proxmox VMs (virtio, first NIC)
|
||||
vmStorageInterface = "ens19"; # cluster-internal NIC in HA VMs (vmbr1 — DRBD + Corosync)
|
||||
vmStorageClientInterface = "ens20"; # storage-client NIC in HA VMs (vmbr2 — iSCSI/NFS VIP)
|
||||
|
||||
# ── Host IPs ──────────────────────────────────────────────────────────────
|
||||
|
||||
pxeServerIp = "192.168.2.223"; # pxe-boot 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
|
||||
torRelayIp = "192.168.2.221"; # tor-relay LXC container 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)
|
||||
domainControllerIp = "192.168.2.253"; # FreeIPA — authoritative DNS for sweet.home (not NixOS-managed)
|
||||
|
||||
# FreeIPA server FQDN used by security.ipa and Kerberos. Must be a
|
||||
# resolvable name (not an IP); resolves to domainControllerIp.
|
||||
ipaServer = "domain-controller.${homeDomain}";
|
||||
|
||||
# ── Cross-host references ─────────────────────────────────────────────────
|
||||
|
||||
# Cross-host references (LAN hostnames/users other hosts reach over the network)
|
||||
nixCacheHost = "nix-cache"; # substituter/remote-builder hostname
|
||||
nfsServerHost = "server"; # NFS export source hostname
|
||||
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 any server — it
|
||||
# exports its own NFS share directly). Resolved as
|
||||
# "${raspberryPiHost}.${tailnetDomain}" in modules/raspi/mount-data.nix.
|
||||
raspberryPiHost = "raspberrypi";
|
||||
|
||||
remoteBuilderUser = "nixremote"; # remote builder SSH user
|
||||
remoteBuilderUser = "nixremote";
|
||||
|
||||
# nix-cache's own SSH host public key (not a secret — the private half
|
||||
# never leaves the host). Wired into every client's
|
||||
# programs.ssh.knownHosts by modules/nix-cache/remote-builder-client.nix
|
||||
# so distributed builds don't hit "Host key verification failed" on a
|
||||
# fresh client that has never manually ssh'd to nix-cache before. Update
|
||||
# this if nix-cache's host key is ever rotated or the host is rebuilt
|
||||
# from scratch.
|
||||
nixCacheHostKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPeWgMsdaiz4axT/deFc1+0B5bN+GX/NOeW9bbQ0c/IT lxc-nix-cache";
|
||||
# Tailscale's internal "Quad100" DNS resolver, reachable from any Tailscale
|
||||
# node via tailscale0. Used by modules/tailscale/ts-dns-forwarder.nix to
|
||||
# forward *.tailnetDomain queries on behalf of FreeIPA's conditional
|
||||
# forwarder zone.
|
||||
tailscaleResolverIp = "100.100.100.100";
|
||||
|
||||
# ── SSH keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
# nix-cache's SSH host public key (not a secret — private half never leaves
|
||||
# the host). Wired into every client's programs.ssh.knownHosts by
|
||||
# modules/nix-cache/remote-builder-client.nix so distributed builds don't
|
||||
# hit "Host key verification failed" on a fresh client. Update if nix-cache
|
||||
# is ever rebuilt with a new host key.
|
||||
nixCacheHostKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICuHUxGNH6ei3BZD+EfZs3l4X8uJNcjQiOsM/G4yo4O/ lxc-nix-cache";
|
||||
|
||||
# Beszel hub's SSH public key — used by every agent to authenticate the
|
||||
# hub's incoming connection. Update if the docker host is ever rebuilt and
|
||||
# the hub generates a new keypair.
|
||||
beszelHubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
|
||||
# Public keys authorized to SSH in as remoteBuilderUser on the nix-cache
|
||||
# host (modules/nix-cache/server.nix) — one per client host that's allowed
|
||||
# to use it as a distributed builder.
|
||||
# host (modules/nix-cache/server.nix) — one per client host allowed to use
|
||||
# it as a distributed builder.
|
||||
remoteBuilderAuthorizedKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFDEA1S2ikpObREgbP5uVBWMxIOGbY8B+Wx7VTZK1m6t root@server"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPAYIT9ormlmxZ0SziyDQaUntnKI8HK9/s3Qac1ZKjP2 root@docker"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKKKzoEPl/ZW9KBRHBcp6/ThOngGpwMv5EhkTlgC4aDf root@nixos"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIGtOWOCS+ImHc7NehguoyD7PbonGosKMZqc9+QR3v/h root@nixos"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHxXTQxFnArK5HXG7czeoybZebCGfxpUdusJkPn+BCSp root@server"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK+ioWPhHixlgCB9KIQ0QTHTz6A+Oo2F3uKiINLip5rO root@docker"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICMJhrfFayLBG+gWtO6oAvgambw5nWWgztiTFEaaaVRH debian@surface"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJEj26SL/emsVjW2YhRucJVp2kTz8WgcEQgjBEBLRikk root@claude"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII/rLceRhnDobVXQYiPceuhDHHvVjFQ1pc9A6un/eUlA root@server"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHj11bLPpRzH2oslnwFzEvY9cSgfEFtSZbLQaDm4nZMK root@pxe-boot"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH4/Sesm8NYpj73R0cbGhI0Ubvz73vIVWAnbEDTlBTdh root@tor-relay"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBCFUUtePndW7pqtlawft1QCdHmBVs3O/c8EJO+RcXV root@tailscale-router"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBZ9WKKAlP9Z7GQdgaZ1Xgw9C+vja2lqEZO5rJFpVqYN root@ha-server-1"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGNKlaaMckd8nLWNGz4B2QokXjnnIvM+rEUv+R6h0sp root@ha-server-2"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK+XeMco7OxUpjrjZm54HogMs9QB5xlcKmElASRvrmlW root@nixos"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMseQwPpmaa6cgV5U8KhUsiVSYARG85zGa9rho0LJWks wayne@pve1"
|
||||
];
|
||||
|
||||
# Admin SSH public key, authorized on the primary user of every host and
|
||||
# the installer image's nixos/root users.
|
||||
# Primary admin SSH public key, authorized on the primary user of every
|
||||
# host and the installer image's nixos/root users.
|
||||
adminSshKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCq/Q5LvIXlZwO2kdeAN5nLGZ59nZB7JHYMEszHxmNtGMzv1lM31jiPNsr0z2EKVZhE7OOfa2IF9rhWYD7JUA9G0yzdZ4WTXFNGVVOJoOVH6vAF3XCxoVilOEwTc7h2Wiy+rzd0B28/3spffzQQWJhY6GRQVa8j+6xAGF60Fcvl1vLosYT9Bn2ZbK4TCWOwAn2jqXIieGpZdn/UNZbGOeKRiCvhktDfMAzuQzN/9jMu/oF4pkPn2X1UrsQdNlvp0Ci8md612MozIpncQJyAF1ADhunr3sMx0isUXiqD29R5DS4TftpekqLNLak+zcxFa8N7DcRNp3DcKfJvyTkwQrR4r+b7lFLYOLHLagSso9CzeW/paAS2q9I5SBm/2DtE1diLLg2jZikYcstsu/G5RgvbzbKqjiaMwTdXC3AMvDxQrs7U5pDRZFzoofG3cpODbTm+uy3m0kP70z0M1K45UbDG0p+itnTu9x40JbQEgefbx38AItNvAIx1A8HO4I1VX28= wayne@stream";
|
||||
|
||||
# Additional SSH keys granted access alongside adminSshKey on every host
|
||||
# (modules/common/configuration.nix) and on HA cluster root
|
||||
# (modules/ha/cluster-config.nix). Single definition here prevents the
|
||||
# two modules from drifting out of sync.
|
||||
extraAdminSshKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICMJhrfFayLBG+gWtO6oAvgambw5nWWgztiTFEaaaVRH debian@surface"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGygkCljN6uKpdJbHTOQtn8ZnH+wKXDLAwrDFbLrE/65 nixos@nixos"
|
||||
];
|
||||
|
||||
# ── Wifi ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Prestaged wifi SSID for the gui host's NetworkManager profile
|
||||
# (modules/networking/wifi.nix). The password is not here -- it's
|
||||
# sops-encrypted in secrets/gui.yaml (wifi-password) instead, since this
|
||||
# file isn't a secret store.
|
||||
# (modules/networking/wifi.nix). Password is sops-encrypted in
|
||||
# secrets/gui.yaml (wifi-password) — not stored here.
|
||||
wifiSsid = "nbn-fttp-net-5G";
|
||||
|
||||
# Bare-metal gui host's two disks for a ZFS RAID0 (striped) root pool
|
||||
# (modules/disko/baremetal.nix). Only used transiently at disko-format
|
||||
# time (partitioning); the resulting fileSystems/zpool import reference
|
||||
# by-partlabel/by-id paths afterward regardless, same as
|
||||
# modules/disko/proxmox.nix's own plain "/dev/sda".
|
||||
# ── Bare-metal GUI host ───────────────────────────────────────────────────
|
||||
|
||||
# Two disks for the ZFS RAID0 (striped) root pool on baremetal-gui
|
||||
# (modules/disko/baremetal.nix). Only referenced at disko-format time;
|
||||
# afterward the pool imports by-partlabel/by-id paths regardless.
|
||||
guiRootDisk1 = "/dev/sda";
|
||||
guiRootDisk2 = "/dev/sdb";
|
||||
|
||||
# System
|
||||
# ── System / users ────────────────────────────────────────────────────────
|
||||
|
||||
timeZone = "Australia/Brisbane";
|
||||
|
||||
# Main interactive user on every host. Every module that grants this user
|
||||
# a group, a home directory, or tmpfiles ownership should reference
|
||||
# vars.primaryUser rather than the literal "nixos", so renaming it is a
|
||||
# one-line change.
|
||||
# Main interactive user on every host. Modules that grant this user a
|
||||
# group, home directory, or tmpfiles ownership reference this so a rename
|
||||
# is a one-line change here.
|
||||
primaryUser = "nixos";
|
||||
|
||||
# Storage
|
||||
storageRoot = "/tank"; # ZFS pool root on `server`
|
||||
# Primary IPA/domain user. Home Manager is configured for this user on
|
||||
# every IPA-enrolled host (modules/ipa/client.nix).
|
||||
ipaUser = "wayne";
|
||||
|
||||
# NFS datasets exported from `storageRoot` on `nfsServerHost` and mounted
|
||||
# by client hosts. `subpath` is relative to `storageRoot` — combined with
|
||||
# it to build both the export line in modules/build-types/server.nix and
|
||||
# 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.
|
||||
# GID of the IPA "docker-access" group (GID 50010 on the IPA server). The
|
||||
# local "docker" group is pinned to this GID on every Docker host so IPA
|
||||
# group membership alone grants socket access — no per-host
|
||||
# users.groups.docker.members entries needed.
|
||||
dockerAccessGid = 50010;
|
||||
|
||||
# ── HA file-server cluster ────────────────────────────────────────────────
|
||||
#
|
||||
# Three network segments, all internal to pve1:
|
||||
# LAN VLAN 2 / vmbr0 / 192.168.2.x — management only
|
||||
# Cluster VLAN 10 / vmbr1 / 192.168.10.x — DRBD replication + Corosync ring0
|
||||
# Storage-client VLAN 20 / vmbr2 / 192.168.20.x — iSCSI + NFS client access
|
||||
#
|
||||
# The host octet is consistent across subnets: node1 = .228, node2 = .227,
|
||||
# VIP = .229 everywhere.
|
||||
#
|
||||
# Protocol separation (firewall-enforced on HA nodes):
|
||||
# NFS — both subnets; LAN VIP for pxe-boot/LAN clients, storage VIP for docker
|
||||
# iSCSI — storage-client subnet only
|
||||
|
||||
haServer1Host = "ha-server-1";
|
||||
haServer2Host = "ha-server-2";
|
||||
|
||||
haServer1Ip = "192.168.2.228"; # LAN IP, node 1 (vmbr0 / ens18)
|
||||
haServer2Ip = "192.168.2.227"; # LAN IP, node 2 (vmbr0 / ens18)
|
||||
|
||||
haServer1StorageIp = "192.168.10.228"; # cluster-net IP, node 1 (vmbr1 / ens19, VLAN 10)
|
||||
haServer2StorageIp = "192.168.10.227"; # cluster-net IP, node 2 (vmbr1 / ens19, VLAN 10)
|
||||
haStorageCidr = "192.168.10.224/29"; # cluster subnet — VLAN 10, internal to pve1
|
||||
haStoragePrefixLength = 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 (Pacemaker vip-storage, VLAN 20)
|
||||
haServerLanVip = "192.168.2.229"; # LAN floating VIP (Pacemaker vip-lan) — NFS for LAN clients
|
||||
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
|
||||
haClientPrefixLength = 24;
|
||||
|
||||
haStorageRoot = "/srv/ha-data"; # XFS-over-DRBD mount point on the Active node
|
||||
|
||||
# NFS VIP FQDNs — use these in fileSystems device strings so mounts
|
||||
# survive a future VIP renumber via a DNS-only update, not a NixOS rebuild.
|
||||
haStorageNfsFqdn = "nfs.storage.home"; # storage-client VIP (VLAN 20) — docker + future swarm
|
||||
haLanNfsFqdn = "ha-vip-lan.${homeDomain}"; # LAN VIP (VLAN 2) — pxe-boot + other LAN clients
|
||||
|
||||
haIscsiIqn = "iqn.2026-01.home.sweet:ha-storage";
|
||||
|
||||
# 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 between VMs depending on disk-add order). drive-scsi1 is always
|
||||
# the data disk; drive-scsi0 is the OS disk.
|
||||
haServerDrbdDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1";
|
||||
|
||||
# ── Docker Swarm cluster ──────────────────────────────────────────────────
|
||||
#
|
||||
# Three network segments, all internal to pve1:
|
||||
# LAN vmbr0 192.168.2.0/24 — management; SSH + external service traffic
|
||||
# Storage-client vmbr2 192.168.20.0/24 — NFS from HA cluster VIP (shared with HA nodes)
|
||||
# Swarm cluster vmbr3 192.168.30.0/24 — Docker Swarm gossip + VXLAN overlay
|
||||
#
|
||||
# Host octet consistent across subnets: node1 = .230, node2 = .231.
|
||||
# IPs from the .230–.239 expansion buffer documented in docs/ip-addressing.md.
|
||||
#
|
||||
# Docker Swarm uses --advertise-addr and --data-path-addr on the swarm NIC
|
||||
# (ens20/vmbr3) so all inter-node cluster traffic stays on the isolated
|
||||
# internal bridge and never crosses the LAN.
|
||||
#
|
||||
# When expanding to a second Proxmox node, vmbr3 (VLAN 30) and vmbr1
|
||||
# (VLAN 10) share the same inter-node trunk NIC via VLAN tagging — same
|
||||
# physical wire, different VLAN IDs.
|
||||
|
||||
haDocker1Host = "ha-docker-1";
|
||||
haDocker2Host = "ha-docker-2";
|
||||
|
||||
haDocker1Ip = "192.168.2.230"; # LAN management NIC (ens18, vmbr0)
|
||||
haDocker2Ip = "192.168.2.231";
|
||||
|
||||
haDocker1StorageIp = "192.168.20.230"; # storage-client NIC (ens19, vmbr2)
|
||||
haDocker2StorageIp = "192.168.20.231";
|
||||
|
||||
haDocker1SwarmIp = "192.168.30.230"; # swarm cluster NIC (ens20, vmbr3)
|
||||
haDocker2SwarmIp = "192.168.30.231";
|
||||
|
||||
haDockerSwarmCidr = "192.168.30.0/24";
|
||||
haDockerSwarmPrefixLength = 24;
|
||||
|
||||
# NIC names for ha-docker VMs. ens19/ens20 occupy the same guest bus
|
||||
# positions as vmStorageInterface/vmStorageClientInterface on ha-server VMs
|
||||
# but are attached to different bridges — storage (vmbr2) and swarm (vmbr3)
|
||||
# respectively. Kept as named variables to avoid bare literals in modules.
|
||||
haDockerStorageInterface = "ens19"; # vmbr2 — NFS client
|
||||
haDockerSwarmInterface = "ens20"; # vmbr3 — Docker Swarm gossip + VXLAN
|
||||
|
||||
# ── Storage / NFS ─────────────────────────────────────────────────────────
|
||||
|
||||
# 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
|
||||
# haStorageRoot; `mountpoint` is the absolute local path on each client.
|
||||
# Renaming a share only requires changing it here — exports and all client
|
||||
# mounts follow automatically.
|
||||
nfsShares = {
|
||||
dockerConfig = {
|
||||
subpath = "docker/config";
|
||||
mountpoint = "/mnt/docker/config";
|
||||
};
|
||||
dockerDatabases = {
|
||||
subpath = "docker/databases";
|
||||
mountpoint = "/mnt/docker/databases";
|
||||
};
|
||||
dockerVolumes = {
|
||||
subpath = "docker/volumes";
|
||||
mountpoint = "/mnt/docker/volumes";
|
||||
};
|
||||
nextcloudData = {
|
||||
subpath = "docker/nextcloud-data";
|
||||
mountpoint = "/mnt/nextcloud-data";
|
||||
};
|
||||
raspiVolumes = {
|
||||
subpath = "raspi/volumes";
|
||||
mountpoint = "/mnt/raspi-backup";
|
||||
};
|
||||
options = "(rw,sync,no_subtree_check,no_root_squash)";
|
||||
dockerConfig = { subpath = "docker/config"; mountpoint = "/mnt/docker/config"; };
|
||||
dockerDatabases = { subpath = "docker/databases"; mountpoint = "/mnt/docker/databases"; };
|
||||
dockerVolumes = { subpath = "docker/volumes"; mountpoint = "/mnt/docker/volumes"; };
|
||||
nextcloudData = { subpath = "docker/nextcloud-data"; mountpoint = "/mnt/nextcloud-data"; };
|
||||
raspiVolumes = { subpath = "raspi/volumes"; mountpoint = "/mnt/raspi-backup"; };
|
||||
proxmoxIsos = { subpath = "proxmox/iso"; mountpoint = "/mnt/iso"; };
|
||||
proxmoxLxcImages = { subpath = "proxmox/lxc"; mountpoint = "/mnt/lxc"; };
|
||||
pxebootImages = { subpath = "pxe-boot/images"; mountpoint = "/mnt/pxe-images"; };
|
||||
};
|
||||
|
||||
# The Raspberry Pi's own NFS export — not under storageRoot/nfsServerHost,
|
||||
# served directly by the Pi itself over Tailscale (see raspberryPiHost
|
||||
# above) and mounted at raspiMountpoint by modules/raspi/mount-data.nix.
|
||||
# The Raspberry Pi's own NFS export — not under haStorageRoot, served
|
||||
# directly by the Pi over Tailscale (see raspberryPiHost) and mounted by
|
||||
# modules/raspi/mount-data.nix.
|
||||
raspiNfsPath = "/home/raspi/raspi";
|
||||
raspiMountpoint = "/mnt/raspi";
|
||||
|
||||
# ── Ports ─────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Every literal port referenced from modules/ or hosts/, grouped by the
|
||||
# service/host that opens or connects to it — kept as separate entries
|
||||
# even where two happen to share a number today (e.g. nixCacheHttp and
|
||||
# pxeBootHttp are both 80) so changing one service's port can never
|
||||
# silently change an unrelated one.
|
||||
# service that opens or connects to it. Kept as separate entries even where
|
||||
# two share a number today (e.g. nixCacheHttp and pxeBootHttp are both 80)
|
||||
# so changing one service's port never silently changes another.
|
||||
|
||||
ports = {
|
||||
# nix-cache's nginx reverse proxy in front of nix-serve
|
||||
# (modules/nix-cache/server.nix).
|
||||
# (modules/nix-cache/server.nix)
|
||||
nixCacheHttp = 80;
|
||||
|
||||
# pxe-boot's nginx asset server, also used to build pxeBaseUrl
|
||||
# (modules/build-types/pxe-boot.nix).
|
||||
# pxe-boot's nginx asset server; also used to build pxeBaseUrl
|
||||
# (modules/build-types/pxe-boot.nix)
|
||||
pxeBootHttp = 80;
|
||||
|
||||
# pxe-boot's atftpd TFTP server — UDP, not TCP
|
||||
# (modules/build-types/pxe-boot.nix).
|
||||
# pxe-boot's atftpd TFTP server — UDP (modules/build-types/pxe-boot.nix)
|
||||
pxeBootTftp = 69;
|
||||
|
||||
# `server`'s NFS exports need both the portmapper (rpcbind) and the
|
||||
# NFS data port itself opened (modules/build-types/server.nix).
|
||||
# DHCP proxy port opened by dnsmasq on the pxe-boot host
|
||||
# (modules/build-types/pxe-boot.nix)
|
||||
dhcp = 67;
|
||||
|
||||
# DNS port opened on tailscale-router for FreeIPA's conditional forwarder
|
||||
# (modules/tailscale/ts-dns-forwarder.nix)
|
||||
dns = 53;
|
||||
|
||||
# NFS stack: portmapper (rpcbind), NFS data, and mountd RPC service.
|
||||
# Mountd is pinned to a fixed port so the firewall can whitelist it
|
||||
# without opening rpcbind's full dynamic range. All three need TCP + UDP
|
||||
# (modules/build-types/ha-server.nix).
|
||||
nfsRpcbind = 111;
|
||||
nfsd = 2049;
|
||||
nfsMountd = 20048;
|
||||
|
||||
# Opened on the docker host's firewall for the Traefik-fronted
|
||||
# container stack (docker-compose config lives in the separate
|
||||
# /home/debian/docker repo, not here): 80/443 are Traefik's own
|
||||
# HTTP/HTTPS listeners; 8080 is an additional exposed service whose
|
||||
# exact backend isn't declared in this repo (modules/build-types/docker.nix).
|
||||
# HA cluster ports (modules/ha/cluster-config.nix)
|
||||
haServerDrbd = 7789; # DRBD replication (TCP)
|
||||
haServerIscsi = 3260; # iSCSI target (TCP)
|
||||
haServerCorosync1 = 5404; # Corosync totem ring (UDP)
|
||||
haServerCorosync2 = 5405; # Corosync totem ring (UDP)
|
||||
haServerCorosyncCrypto = 5407; # Corosync crypto sync (UDP)
|
||||
haServerPacemakerRemoted = 3121; # pacemaker-remoted (TCP)
|
||||
haServerPcsd = 2224; # pcsd cluster daemon (TCP)
|
||||
|
||||
# Docker host — Traefik HTTP/HTTPS listeners plus one additional exposed
|
||||
# service (modules/build-types/docker.nix)
|
||||
dockerHttp = 80;
|
||||
dockerHttps = 443;
|
||||
dockerExtra = 8080;
|
||||
|
||||
# Beszel monitoring hub, reachable at
|
||||
# http://<dockerHost>.<homeDomain>:<beszelHub> from every agent
|
||||
# (modules/beszel/enable-agent.nix, hosts/nixos/home.nix).
|
||||
# Docker Swarm inter-node ports (modules/build-types/ha-docker.nix).
|
||||
# Firewalled to haDockerSwarmCidr only — vmbr3 is an isolated bridge
|
||||
# with no physical uplink, so these ports are unreachable from LAN.
|
||||
dockerSwarmMgmt = 2377; # TCP — Raft consensus + cluster management
|
||||
dockerSwarmDisc = 7946; # TCP+UDP — Serf gossip (container network discovery)
|
||||
dockerSwarmVxlan = 4789; # UDP — VXLAN overlay data path
|
||||
|
||||
# Beszel monitoring hub on docker.sweet.home, reached by every agent
|
||||
# (modules/beszel/enable-agent.nix, hosts/nixos/home.nix)
|
||||
beszelHub = 8090;
|
||||
|
||||
# Proxmox VE and Proxmox Backup Server web UIs, opened as desktop
|
||||
# shortcuts on the gui build type (hosts/nixos/home.nix).
|
||||
# Proxmox VE and PBS web UIs — desktop shortcuts on the gui build type
|
||||
# (hosts/nixos/home.nix, modules/build-types/gui.nix)
|
||||
pveWeb = 8006;
|
||||
pbsWeb = 8007;
|
||||
|
||||
# Tor relay's ORPort — the port other Tor relays connect to for onion
|
||||
# routing traffic (modules/tor/enable-relay.nix). Tor's own conventional
|
||||
# default; opened via services.tor.openFirewall rather than
|
||||
# networking.firewall.allowedTCPPorts directly, but kept here anyway so
|
||||
# it's not a bare literal duplicated between the relay's settings and
|
||||
# anything else that ever needs to reference it.
|
||||
# Tor relay's ORPort (modules/tor/enable-relay.nix). Opened via
|
||||
# services.tor.openFirewall rather than allowedTCPPorts directly, but
|
||||
# kept here so it's not a bare literal if ever referenced elsewhere.
|
||||
torRelayOrPort = 9001;
|
||||
};
|
||||
|
||||
# .raw disk image size for every proxmox-* host's standalone Disko image
|
||||
# build (modules/disko/proxmox.nix, config.system.build.diskoImagesScript
|
||||
# — see docs/proxmox-images.md). Root fills whatever's left after the ESP
|
||||
# and swap partitions within this total.
|
||||
proxmoxImageSize = "20G";
|
||||
# ── Build / image settings ────────────────────────────────────────────────
|
||||
|
||||
# nix-cache's Nix store garbage collection retention
|
||||
# (modules/nix-cache/server.nix).
|
||||
# .raw disk image size for every proxmox-* host's standalone Disko image
|
||||
# build (modules/disko/proxmox.nix — see docs/proxmox-images.md).
|
||||
proxmoxImageSize = "50G";
|
||||
|
||||
# nix-cache Nix store GC retention (modules/nix-cache/server.nix)
|
||||
nixCacheGcMaxAge = "30d";
|
||||
|
||||
# Traefik access log rotation, watched on the docker host at
|
||||
# nfsShares.dockerVolumes.mountpoint (modules/traefik/rotate-logs.nix).
|
||||
# nfsShares.dockerVolumes.mountpoint (modules/traefik/rotate-logs.nix)
|
||||
traefikLogRotate = {
|
||||
maxSize = "100M"; # rotate once a log file exceeds this size
|
||||
keep = 20; # number of rotated logs to retain before deleting the oldest
|
||||
keep = 20; # number of rotated logs to retain
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPwUaVHP4MHZXicMAYGOe2ME1tp+CJShr8WocevlGWMh baremetal-gui
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:iVu2256Uzr2ThOGmFvCe48vWxCTJFflKUZ5vFSlFn+NomD69+y7RXf0ZICYOIH9t8pDAT3ZQDoMDw/kbPHxIj2R2U4AAt2H5QmKddTK3YCR8GgPde6Jp//v8NxLv+ZkAfj4W532rdaJ4CYrga9m8rOZi/6FubldUjBAVLVBiHxX86nCMbP5z2YTdHi2/IfEVK4Agy63oPZ0yD0T7QcXEX5F5eMcd6ceIDjJoROsQw5yVwUnjREo/Sgv5mQy9wBaf4ZtLw+eY5mJECIXjxhSkz417N31GB0D57A9N+WrEm8BEeuIbd+x7zdTSnZRsCZXLI3HRmkFH8bK75G9kPpXrpTQ5TT58LwDNuAGUK9K6dS/h8WAD8JRhtLhnEdCZObpxwoxOT2cCredwYZSdoVVirhuQI5EsGiTU1SGP78guAqpXvwvfbgIo2Qvic/e9OPQCHWLO4YrWonc6VUHhh/HmPOmaSrMcCFHE027fGXP8uTUEzOyE5hzmcM0d0WOX26xJ7PttbdKcLF3dZDnJQphn,iv:vpaX2prOsN/4Ke2HvoNHHp86DQpcPC6EtfgWvlgRGlQ=,tag:2Fv0oy99E88owAnB1fkvDw==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsU2pBT3M1TGJUYVlWUjNp\nN3JudVdVamN0TnF6cjlXN3BJRW5HbDFIZTBZCmhyRlQyNkl4bjBjOEhMZVhmQWxK\nVEJvaEpnTDREOXlKV3VxYzdkcW5tUEkKLS0tIHROY2RZeXFKQXBUeTZLODEzTGxZ\nSHl4WFhmOWVVR2NEcUxQSCtObU8xWkUKa/qmGyWYuEjf+BCoag5H9cA4ovW+ro8V\nhGsi3GqPEFfrI+qx/e6JqxazYpwfwIEaMZljhfgoFzg0I1U7OsnpSQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-25T11:45:21Z",
|
||||
"mac": "ENC[AES256_GCM,data:7xbxAir+/3FbbJU4L4qRpZIYr/gqyoHjGxJvYYVLZ5yZLs8k/UiTG/qlu2zVkN2yfQaUzAu4/bQco5vOin6pwFodRH8BK4fbVIOheExzN1fk2uayejt/wWRiWj8w+qH+HaWLejlvrmbNced9HTiGViAHa+EZv3OUphlZKAdk/3Q=,iv:ZBuC8htjmKKc84kCBRHj7TdRdiPaEGeVMl+8ydOfIMc=,tag:uO4evh0xShWmG8kY/AkOWQ==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO+Ub4qK5Asqgi3A17zEy/+VOQ4ozL85ZBvlJnyhLZHD linode-docker
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:KpK5pPE33YtraIHSylq1MmlDiRvpDdwz9QTHsZCVrGuB3M19y5EXCBnARXreEHQyRgXWgeL4XZUKhR10xzeq/TqqZdKROtGn+LNwDM2f0OxLLxwPoqAqGQ2yEH2WbQdui28zEPcG1qKxzSVH2P2LIdLLghVTVeR1MM+sWgcJC5aoQGjjHt1tgGdQDv/jfGz9gDB9KRlJ6BVCdOdbkR4Ui5QFJX3edwvHRXsZUvlDNoVNrnQnGXUtw9FI2Ma+4mZ06x9Jv53LgAGsVf+Bu5USAn2VUKZDZu6Tfcdg8gb9iDKQrb+1jDq/dIb4/rh8fJwynMyG+pdrLtAoHs1D2K/3itglifs4sX8XDxSotMJWBB/KZUqdI2TIjv3fnBgkENbMFiv2rboWULRKA8z+viP2phS7kwm+mp7bGqRn36Bs90O5iVgifu/PYVykdnKggDbCyTbuKqdoq/C/s2oO7WbQISmjj+HjRrQ4wQd74iUvYjV2364AhwDQ+1L/OCR7HYr/l/NRpBcrjI5Kxaa5Usef,iv:GfqfGPjKxpOhdLJvJWTduYe2FbIKrMlKevErvnxfOa0=,tag:khM375z1e04y/JebLz/VgA==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSbS96Rmc4eDJkNGNUV2c3\nS3FCYnY1TCtOWXh1MzBPbnRnL09VbzNxblZBCkp5NkpxUXlJbUFjay9KbjVZeGc3\nTzgxRk0yKzU0SFBGelBDZnVHVzNaNlUKLS0tIFZRWXFUVW56a2owWk9TYXZPTE9r\nbmZHcG5ZN1dla3pXa3ZhOUdjR2dyTVkKaqAlfNkc2wzjB2//7DzW7JWg2BZd0Vqe\nO9YttWf4ikU6vfM+M/yiWGpWJ4U8p/3PBmT3ZLQBRGRtcyPby5DxZQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-25T11:45:23Z",
|
||||
"mac": "ENC[AES256_GCM,data:+arWx20i4iAlFhbzH8/R9jggzJEP2PlEzyBnbAiDYZhp+2vfbsn+28WBzmjhi4AXz8CbBrhhFS6IFmYQZSFyYFuT943qoo82d/8E6p1DoyEEBL856SmkuVKpCq6IpWEJmkj8j7z3yyrpXva0aagReP+8+agWI+wWdNmG9M2tBRQ=,iv:Edqregq0OXRM4AWJ9EOtR/dZCObRepHnP4s8SYYkKHo=,tag:bBSiY7EVtli41w9s6JbQVw==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOFHRs2HkjhOL/Ilii8PiwxGkQNsstmGn/Bs2Vx/OWsI linode-gui
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:SRq6nUjsMjpM5CXHpAMN2j20/2LbdvHfwqOOoWMTDkW+oC8tLhQfKHpXzKFcKVfqiqjZqaS8J1pmb59buDuYvShXm21i+nRTfhzOHAvdQJNYghBKVLt10RuIDdgPuhpi7r9y5hPZrF+jFSyk4wLcoE+F9FpktTPZwrGnjd5kT1btehuwbjQFIgl23L9E7BtJMBOecUNc3xTLymaEi26kWga8zFXQMLiyLLP4ZPSrjhSeZpXx6zGj2iIHKnPSsaYTFhfoWbiOIzCZrSpV6GsDIsH4MDEwNjwk8fPL7VuGFoMJev0vM0IGeoKB1Jn7HIe6an1t8DoX1TZHk9NA0L2IJR7nglP5I9CHjDJzQZ4hOZvmy7PZd0M8P4be5sQ72Yl2ZgsX9y4ZhANJ+IvYMI7VM1GamcQqbuYrzYCc+zqVeq75FJYUW/fkcKpQihWjQeSO/PAzevdMH4H1k810MEQJSbQxeBB6xc7bArwip2vMvrmDAvBeJCkkJlDvLW5acnbX5MMiP+FiDOsTrggOSL6B,iv:g87PWSi9TyN4GwZdYAxUbRpJvtoYSon6tIoz4SC5bWg=,tag:hDem9tgzD0/lg44g3Us0vg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByQWUxUHpyZkk5aFNFMTVY\nc09vcHdDUFIwUHA1TVhwblVPMWRiS0dTaHlBCmtHWk9YTVRTcGJndjQ3bmhIWEJL\nMkFVdzNmU3RRUGN6clhZNGRtVnRTQjgKLS0tIFoxWnBsa2Y2MTM2SjBjNjhrUDNx\nU1dyZkhpZHZkb2toeUFpS2tLN2Fic1EKUxQ1J57TJytHqIuLowiSyoS/nJPxSoZI\ni35CHWWE0+Y344m/DmdJPspvYZSI3rY1JbgaqjIKZrshDggZVTka2A==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-25T11:45:25Z",
|
||||
"mac": "ENC[AES256_GCM,data:ARpnwhj3Y88r6FsqPFBUK/Nth/R6Kfel5cVtlKqjkAdK1FB5BMmdxNbbOn2AH7+zlzdH7qRKvkH49CS6P+bfLaIJBkdLanzF0lSzxq8+88fgqFhJi+mroAZGH6OJaVcHVdTytaE2b8xAMeoz07e1Smp07F6Sp5mwHemAB9EHi38=,iv:JrYUaIGeJYLcgKj845irmKPH05tD9z6Rabv1B5tVmZw=,tag:gK5X/dBJvrWF8LTpdhytAg==,type:str]",
|
||||
"version": "3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPp8ZFWz97BFJJxHE+3w0/RpIXpRV64XE0eNBYl3jpSz linode-minimal
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user