Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8ee5ff26 | ||
|
|
b4990d70ef | ||
|
|
59b734995b | ||
|
|
599405ce18 | ||
|
|
4b31255f13 | ||
|
|
6b79c212a8 | ||
|
|
c3950547b0 | ||
|
|
b28d6dd32c | ||
|
|
7ac3775a96 | ||
|
|
de9b069a88 | ||
|
|
2cb0c734d8 | ||
|
|
6f908513e3 | ||
|
|
53f59aa388 | ||
|
|
69e996ff98 | ||
|
|
6e4d0d22a1 | ||
|
|
881587c42a | ||
|
|
1bb7d1c680 | ||
|
|
fd5f651f2c | ||
|
|
b4317c9589 | ||
|
|
07f3e4cef9 | ||
|
|
7592bda57a | ||
|
|
47f496efcf | ||
|
|
4a2c3a8eb4 | ||
|
|
e9e7451a8e | ||
|
|
06c0cfbb78 | ||
|
|
37b0369361 | ||
|
|
81a974d715 | ||
|
|
ff70668f38 | ||
|
|
fbe431555e | ||
|
|
bd4c4f8716 | ||
|
|
201cdb5bf9 | ||
|
|
7ba4adb376 | ||
|
|
a4fc880c38 | ||
|
|
edf6294315 | ||
|
|
b4dd074894 | ||
|
|
e81f6643fc | ||
|
|
afe51c4abd | ||
|
|
a101e73c0e | ||
|
|
1a5c6aca08 | ||
|
|
00f76f33fd | ||
|
|
0a92b8f57a | ||
|
|
8345c97664 | ||
|
|
1f61eb8c7e | ||
|
|
625a307a8d | ||
|
|
c2f3f048b9 | ||
|
|
bd3dbcb057 | ||
|
|
7f975bc4f1 | ||
|
|
31abf40722 | ||
|
|
439021f798 | ||
|
|
181465c376 | ||
|
|
6ec28b1ad7 | ||
|
|
db1a88ab2e | ||
|
|
aa148fe435 | ||
|
|
f4590ff653 | ||
|
|
22402cb4fd | ||
|
|
1868a58ee0 | ||
|
|
50cbd2fa28 | ||
|
|
81e34a4adb | ||
|
|
302eb43233 | ||
|
|
949391ed44 | ||
|
|
5041e5202f | ||
|
|
8baefe1bfd | ||
|
|
d8108dae0f | ||
|
|
3eb347da06 | ||
|
|
8d15b71c06 | ||
|
|
a28ad04b55 | ||
|
|
6720b602ba | ||
|
|
4e2264d5db | ||
|
|
82a6221880 | ||
|
|
6ec1faf3e6 | ||
|
|
d3beee602d | ||
|
|
29960e9937 | ||
|
|
417456485a | ||
|
|
0945092dde | ||
|
|
6f12117521 | ||
|
|
8bf8814fa7 | ||
|
|
d90b5b091b | ||
|
|
4275ac1d2f | ||
|
|
07b36d62d2 | ||
|
|
5fb8279d61 | ||
|
|
6eb63d3f85 | ||
|
|
2702854513 | ||
|
|
106537cc63 | ||
|
|
dabb96e1b3 | ||
|
|
2b5a154b99 | ||
|
|
e475b0f47d | ||
|
|
8f81f8f1e2 | ||
|
|
cd753a7e28 | ||
|
|
7ac1985508 | ||
|
|
0ecf2eb651 | ||
|
|
18c7095aaf | ||
|
|
dcad276c59 | ||
|
|
06988d0ff0 | ||
|
|
69b84153b4 | ||
|
|
df08a7c413 | ||
|
|
602464189f | ||
|
|
67f4cdc99e | ||
|
|
f8c717db25 | ||
|
|
268abddb28 | ||
|
|
c1119b03a8 | ||
|
|
0c273b758d | ||
|
|
6f98c478e8 | ||
|
|
875a6a9297 | ||
|
|
1dbfe3cd94 | ||
|
|
e0d4b3544d | ||
|
|
3e3fbed470 | ||
|
|
ada9f25c41 | ||
|
|
66cacaaf9d | ||
|
|
15cd07d12f | ||
|
|
fae57c0375 | ||
|
|
3745eedd74 | ||
|
|
1cd4fc8b40 | ||
|
|
732ab6f2aa | ||
|
|
bea26c55c3 | ||
|
|
a841665b07 | ||
|
|
d574f96379 | ||
|
|
2388039b63 | ||
|
|
aa69d40f08 | ||
|
|
31cb48cc2b | ||
|
|
170bd14a34 | ||
|
|
2553e0dce0 | ||
|
|
b8e7b2b4cc | ||
|
|
24098a209a | ||
|
|
8ad7509b02 | ||
|
|
a350d4e2f7 | ||
|
|
ec3782991d | ||
|
|
bc4b4630a3 | ||
|
|
342f60ce0d | ||
|
|
8c8e8f43a2 | ||
|
|
559e0218eb | ||
|
|
fd2e12ced1 | ||
|
|
efdf1e05d0 | ||
|
|
cd3ab47aa0 | ||
|
|
c12a680d27 | ||
|
|
c728eee924 | ||
|
|
ca704b24a2 | ||
|
|
db068ba994 | ||
|
|
53ee31c5d2 | ||
|
|
283b439d59 | ||
|
|
9e09f9eb40 | ||
|
|
08bfa73e74 | ||
|
|
2b76a766ad | ||
|
|
d245e2ce0b | ||
|
|
bb2603bea0 | ||
|
|
8f96625c26 | ||
|
|
9c0ddf0dbe | ||
|
|
2a352c35f9 | ||
|
|
56b965b847 | ||
|
|
8e5bb766a6 | ||
|
|
646d877c4d | ||
|
|
045c5d6a12 | ||
|
|
f0f690eae4 | ||
|
|
459536d478 | ||
|
|
6c5f261d8a | ||
|
|
91cc0152ba | ||
|
|
bfc60eeb2c | ||
|
|
976d8f3609 | ||
|
|
10a1d0f7ba | ||
|
|
3a8e9a2dd0 | ||
|
|
6872c8d820 | ||
|
|
175f48ef37 | ||
|
|
49912a2760 | ||
|
|
c450dcab9e | ||
|
|
953fb04671 | ||
|
|
c02655a840 | ||
|
|
f87e9982b0 | ||
|
|
02e662454c | ||
|
|
4d8eaf71ca | ||
|
|
a0f42d3e7b | ||
|
|
ef683a6aa9 | ||
|
|
02a9dbf39c | ||
|
|
6d72f70fe5 | ||
|
|
c2887b60b2 | ||
|
|
9e76bad58a | ||
|
|
b3f6efef8a | ||
|
|
53813e775d | ||
|
|
060f81393c | ||
|
|
c1c0827604 | ||
|
|
b5715e05c6 | ||
|
|
68c3aa95fd | ||
|
|
281b08dcd4 | ||
|
|
ca1ff3ee20 | ||
|
|
1cd5bd4496 | ||
|
|
6512bf4356 | ||
|
|
7da0463dce | ||
|
|
466582bcdc | ||
|
|
c23ae5543d | ||
|
|
569e0de59d | ||
|
|
4a7d9615db | ||
|
|
7c1b603200 | ||
|
|
4dae7836dd | ||
|
|
e821da6c2a | ||
|
|
17fbd5fd2c | ||
|
|
8712ac43c6 | ||
|
|
38e4a296ee | ||
|
|
d1ef6ba1cd | ||
|
|
ffd2029852 | ||
|
|
761af09166 | ||
|
|
48d7e8a459 | ||
|
|
d3327e05d4 | ||
|
|
2c8dd91cf0 | ||
|
|
448c4b9094 | ||
|
|
e147fd8f4d | ||
|
|
b8feea3711 | ||
|
|
0cc1f50aa4 | ||
|
|
71d8eae6d8 | ||
|
|
878392d998 | ||
|
|
d6471aad55 | ||
|
|
4b939affaf | ||
|
|
c1b02e0562 | ||
|
|
6b962ff51d | ||
|
|
3843f8ea22 | ||
|
|
c85eea719d | ||
|
|
5309618747 | ||
|
|
725aad3aac | ||
|
|
070ab61131 | ||
|
|
164f052b1f | ||
|
|
8841a8d628 | ||
|
|
d500d15e12 | ||
|
|
16898e8eb9 | ||
|
|
c809045014 | ||
|
|
158d369371 | ||
|
|
c40db26e6f | ||
|
|
42305f7f22 | ||
|
|
539ede00cb | ||
|
|
5324344eed | ||
|
|
d2c9dd1fbd | ||
|
|
a1db5773fc | ||
|
|
5e33a250d5 | ||
|
|
7e0cda17f3 | ||
|
|
15821207dc | ||
|
|
cdb93ad8dc | ||
|
|
0c596fb396 | ||
|
|
a3c1b849f2 | ||
|
|
7262694425 | ||
|
|
ff1defcaab | ||
|
|
6ac9a7cd4c | ||
|
|
cb9172d069 | ||
|
|
92dd718362 | ||
|
|
7d15b67463 | ||
|
|
12b2d85fb4 | ||
|
|
8657bdc23a | ||
|
|
59cbc8d4e9 | ||
|
|
80fea3301b | ||
|
|
91a3e68119 | ||
|
|
bda9c3cd0e | ||
|
|
a84e958182 | ||
|
|
21e0f284b6 | ||
|
|
e83b4ff5b1 | ||
|
|
0445a1c1cc | ||
|
|
dc1d89b441 | ||
|
|
0da964bfca | ||
|
|
b5e89c38f8 | ||
|
|
c37816d257 | ||
|
|
fce4608647 | ||
|
|
8fd08057d8 | ||
|
|
0563c6b96b | ||
|
|
b29ed2cce7 | ||
|
|
f8ecbf3ee3 | ||
|
|
20aa66a160 | ||
|
|
976d3b0fa7 | ||
|
|
2e9d989444 | ||
|
|
38207e8b2f | ||
|
|
5fe2ecd56d | ||
|
|
846e2af705 | ||
|
|
c8eb452a70 | ||
|
|
46b8c23578 | ||
|
|
db32796675 | ||
|
|
ecd5ecd659 | ||
|
|
99f86e1cda | ||
|
|
630cfef690 | ||
|
|
6c0afc0e6b | ||
|
|
709bd51413 | ||
|
|
37370fd12f | ||
|
|
1e2b11b235 | ||
|
|
d636e0fa38 | ||
|
|
31c7b796f8 | ||
|
|
8a57734a42 | ||
|
|
0b76a257ce | ||
|
|
7a0a43dfd3 | ||
|
|
0d318d60ac | ||
|
|
25fe8844e5 | ||
|
|
d468678d00 | ||
|
|
0af4c391e8 | ||
|
|
5bb8af7a3e | ||
|
|
c86cb9afe0 | ||
|
|
9c34eb0694 | ||
|
|
337f858a7a | ||
|
|
063c76f8ce | ||
|
|
a0e110b376 | ||
|
|
8cf43fd3d1 | ||
|
|
6f63e0f4d0 | ||
|
|
d458d8c07a | ||
|
|
6c7b1587b3 | ||
|
|
be8d5ccf16 | ||
|
|
18c60bf085 | ||
|
|
d3d90f6e94 | ||
|
|
d874c97b2f | ||
|
|
2e93514a4d | ||
|
|
990ded6d1d | ||
|
|
4e501548ac | ||
|
|
2073303b18 | ||
|
|
1651f8de37 | ||
|
|
1b2c0f2c1c | ||
|
|
40c2d17833 | ||
|
|
2ff983f5f4 | ||
|
|
fc6f58b00e | ||
|
|
09c4249cae | ||
|
|
8be2a4fe44 | ||
|
|
d973fae4db | ||
|
|
05c08532b3 | ||
|
|
8970e8a689 | ||
|
|
8d97184105 | ||
|
|
1ce4a2a520 | ||
|
|
97a868e0f9 | ||
|
|
50a2fc0807 | ||
|
|
b6c7c039b2 | ||
|
|
49e8a96aab | ||
|
|
19273e6d10 | ||
|
|
bb07fbd2c3 | ||
|
|
6ea8810881 | ||
|
|
587f2a09f8 | ||
|
|
1d15997745 | ||
|
|
da0c79d479 | ||
|
|
4119a4ef61 | ||
|
|
604eb11584 | ||
|
|
7986de0b63 | ||
|
|
3f345dbc02 | ||
|
|
9998306a0c | ||
|
|
42e2e3dd16 | ||
|
|
6b44c03fd8 | ||
|
|
1931a99e65 | ||
|
|
4ce6341eb3 | ||
|
|
4301629606 | ||
|
|
cf39e28921 | ||
|
|
b252014158 | ||
|
|
86942ebc33 | ||
|
|
13f15cb845 | ||
|
|
5c19de6fb8 | ||
|
|
dfcc3858f0 | ||
|
|
bebca8f1ab | ||
|
|
30b3f14292 | ||
|
|
f24c9c45b2 | ||
|
|
04587efad3 | ||
|
|
023b00d297 | ||
|
|
7ec47abe17 | ||
|
|
9c47c99645 | ||
|
|
cba3d1d092 | ||
|
|
a135e652bc | ||
|
|
6c2cbd5b3b | ||
|
|
7a28b138a9 | ||
|
|
a687c05f6c | ||
|
|
8c4a8e4313 | ||
|
|
2d4a3fcdf2 | ||
|
|
adad79c7e8 | ||
|
|
2e6d88daec | ||
|
|
2cd9d7cf20 | ||
|
|
8500e1de05 | ||
|
|
fefc7ff81a | ||
|
|
1727755942 | ||
|
|
c6bfe1200c | ||
|
|
5095052a53 | ||
|
|
cc9b41fd37 | ||
|
|
8dedd59cc0 | ||
|
|
57d12aab9e | ||
|
|
99413a5dbe | ||
|
|
a23e9f5c45 | ||
|
|
cfadd90d24 | ||
|
|
ac47f39117 | ||
|
|
55cd583569 | ||
|
|
77bdf710c7 | ||
|
|
60723689e1 | ||
|
|
7576c0fe85 | ||
|
|
c5bbb5220e | ||
|
|
57dcf312bc | ||
|
|
9fe6e108a9 | ||
|
|
a6dc3fd647 | ||
|
|
e33f4b570a | ||
|
|
18b454e07b | ||
|
|
20b1486547 | ||
|
|
068c78bd27 | ||
|
|
32b2ee7117 | ||
|
|
9b5786fce1 | ||
|
|
9c15b458c4 | ||
|
|
b86fe94d82 | ||
|
|
7b7947db9d | ||
|
|
38b45013b3 | ||
|
|
2db344f91f | ||
|
|
a8d182ffc5 | ||
|
|
75d3aca1b6 | ||
|
|
21be0af9c1 | ||
|
|
f2a94fa1b5 | ||
|
|
a086ab689e | ||
|
|
5b0babed1f | ||
|
|
df88f7c30a | ||
|
|
fc5432398f | ||
|
|
bf9f596bb3 | ||
|
|
40dd601e21 | ||
|
|
2f67a91b70 | ||
|
|
5e4e2c8d71 | ||
|
|
e47a238cc8 | ||
|
|
5678b69d4f | ||
|
|
6126fbf0ca | ||
|
|
7244f559c1 | ||
|
|
892305e416 | ||
|
|
6da8730453 | ||
|
|
08b3e5645e | ||
|
|
852098439e | ||
|
|
be6766871a | ||
|
|
0e1dbb8809 | ||
|
|
d82d871b88 | ||
|
|
9d81af72ff | ||
|
|
40180b5cb7 | ||
|
|
b25c077835 | ||
|
|
97a7a9163e | ||
|
|
75e79f2a16 | ||
|
|
340c1cd0f5 | ||
|
|
211a32db45 | ||
|
|
1da6fb9cd6 | ||
|
|
5bdcba8a90 | ||
|
|
9f47fd6ba3 | ||
|
|
0f8cdc9376 | ||
|
|
aaa2743fcc | ||
|
|
377bb63122 | ||
|
|
a2269c927e | ||
|
|
314123fcd8 | ||
|
|
e6c76e636e | ||
|
|
5084d6ebb8 | ||
|
|
b610e76659 | ||
|
|
3fd000ac4e | ||
|
|
3a59944967 | ||
|
|
533c981a70 | ||
|
|
92b46d1bba | ||
|
|
317157de2d | ||
|
|
543a9df0bf | ||
|
|
8f792bb192 | ||
|
|
7ba223a25a | ||
|
|
9db77b84bd | ||
|
|
8af03e53db | ||
|
|
e190cce593 | ||
|
|
c498064e80 | ||
|
|
56652c37f4 | ||
|
|
8565cda7b4 | ||
|
|
18124ff2a1 | ||
|
|
d61211e300 | ||
|
|
17e6d0d180 | ||
|
|
46a112a8e1 | ||
|
|
a8efb3d880 | ||
|
|
be0eebdb8b | ||
|
|
8caee2ec22 | ||
|
|
fe51d69700 | ||
|
|
116197e9fb | ||
|
|
56e1da93c1 | ||
|
|
02e40e6634 | ||
|
|
650d693849 | ||
|
|
1dac6a0527 | ||
|
|
17f89fa773 | ||
|
|
38acee7319 | ||
|
|
7cba8e6258 | ||
|
|
ec641b1b9b | ||
|
|
008a003fa1 | ||
|
|
8310028546 | ||
|
|
ac235f2e38 | ||
|
|
e3ecdfafb7 | ||
|
|
37bc0c6192 | ||
|
|
536b3bfa78 | ||
|
|
57de0d31a7 | ||
|
|
bd1acfa266 | ||
|
|
a05ca90b2d | ||
|
|
ece73148cd | ||
|
|
3ead52583f | ||
|
|
c7005c93b5 | ||
|
|
d2d2ed58a6 | ||
|
|
a48fe1c882 | ||
|
|
d07ea9a227 | ||
|
|
0f77c6834c | ||
|
|
f1188abff5 | ||
|
|
f66de58b78 | ||
|
|
90d423e94b | ||
|
|
e1a06079fd | ||
|
|
f0f9f6854c | ||
|
|
73dd4fbb4b | ||
|
|
2edc4c1829 | ||
|
|
9dab44f7e3 | ||
|
|
c8fb773be4 | ||
|
|
5d43f7e0d7 | ||
|
|
31f1e16a3c | ||
|
|
458b8fae0b | ||
|
|
6e5863ed48 | ||
|
|
70d5286f87 | ||
|
|
ad6cf6c498 | ||
|
|
51537c47b3 | ||
|
|
82ecf581c3 | ||
|
|
a5ff38786c | ||
|
|
eb3463cdfe | ||
|
|
ad4337c12f | ||
|
|
d5b16da57e | ||
|
|
1a7ed3cb6c | ||
|
|
1ecd1e1470 | ||
|
|
7e1c7b0dbd | ||
|
|
ab1150266b | ||
|
|
97ceb0c0ce | ||
|
|
e41b78897d | ||
|
|
67678b7927 | ||
|
|
903be87154 | ||
|
|
b767b6d53b | ||
|
|
35e56afa0f | ||
|
|
aae1aa8779 | ||
|
|
3ca15d0da4 | ||
|
|
af14622e45 | ||
|
|
8f8cc15ff4 | ||
|
|
8f97aa416f | ||
|
|
9ec8618f7d | ||
|
|
163afcc7e3 | ||
|
|
6fc66ba13f | ||
|
|
357624193b | ||
|
|
5a3432a93b | ||
|
|
ccdde31654 | ||
|
|
9c8f359c0d | ||
|
|
e9b94123b5 | ||
|
|
6aa7a5a40b | ||
|
|
1a742578d1 | ||
|
|
0af6a7cf66 | ||
|
|
bd9f889982 | ||
|
|
1242f0bc0b | ||
|
|
1bda7e9920 | ||
|
|
03b711247e | ||
|
|
86672c3c28 | ||
|
|
47049a94bd | ||
|
|
7bda7abeb8 | ||
|
|
db093a04ad | ||
|
|
df353d1318 | ||
|
|
78252b9d07 | ||
|
|
db3ff345cf | ||
|
|
d398faa0ed | ||
|
|
c3558b4505 | ||
|
|
7f135da474 | ||
|
|
7fdee314c8 | ||
|
|
cfd9d4c284 | ||
|
|
1efd5c2086 | ||
|
|
bd5ae05e20 | ||
|
|
bcdef677b7 | ||
|
|
785e5539b3 | ||
|
|
21ea5e5303 | ||
|
|
04a675cb84 | ||
|
|
b331c49b61 | ||
|
|
21723a6860 | ||
|
|
3d646fec0a | ||
|
|
4f997b1c4a | ||
|
|
0fb532d46f | ||
|
|
5bb2c67b1b | ||
|
|
e8b1195c66 | ||
|
|
a415431d93 | ||
|
|
885fc7f099 | ||
|
|
95440529ef | ||
|
|
8ea133a2a1 | ||
|
|
701c400916 | ||
|
|
b4ca6fe7fe | ||
|
|
1843c98e98 | ||
|
|
2cfb23e670 | ||
|
|
54fb17c9c8 | ||
|
|
61cee57d4e | ||
|
|
dca53835f4 | ||
|
|
385a3cb215 | ||
|
|
2bf9c6657b | ||
|
|
d77dde4020 | ||
|
|
967df9664d | ||
|
|
489e326ccc | ||
|
|
5cd9b6bb3d | ||
|
|
fa71a7da97 | ||
|
|
5bd5c03e2f | ||
|
|
bf9e82cd79 | ||
|
|
a18ef1447a | ||
|
|
ecfd2e9f51 | ||
|
|
f8bdc1cb15 | ||
|
|
431773f6d5 | ||
|
|
2fae4ccc79 | ||
|
|
477d265de8 | ||
|
|
6b14f811d6 | ||
|
|
8e8082d2ae | ||
|
|
b073564cce | ||
|
|
586eba824a | ||
|
|
9e081bec05 | ||
|
|
4eac3c3498 | ||
|
|
d9fba84243 | ||
|
|
f5d44e5c4b | ||
|
|
b17ccce53a | ||
|
|
a9e4bc2656 | ||
|
|
c4238f2590 | ||
|
|
059eaefa0c | ||
|
|
cfa103f7b5 | ||
|
|
1bbf6094b3 | ||
|
|
73401353e4 | ||
|
|
64c32dfb4f | ||
|
|
ebd41797f7 | ||
|
|
4c5e639cfa | ||
|
|
4aaea32c39 | ||
|
|
e3916d48dd | ||
|
|
f7af81eba4 | ||
|
|
0bafcee8af | ||
|
|
b77fb2ed70 | ||
|
|
48926d1937 | ||
|
|
d3458da56b | ||
|
|
45ee8da166 | ||
|
|
f15e5616b7 | ||
|
|
65e4086682 | ||
|
|
0f1ebe339e | ||
|
|
4bbf7a3a67 | ||
|
|
6012373501 | ||
|
|
71a08ae4d3 | ||
|
|
41f17c205c | ||
|
|
72bec5b77f | ||
|
|
bcd4a49942 | ||
|
|
b47f0986d5 | ||
|
|
4c67e3984f | ||
|
|
08e8d6bf69 | ||
|
|
3365ab5639 | ||
|
|
973d2cec92 | ||
|
|
fb4c268b8e | ||
|
|
3673ccf39b | ||
|
|
21bf0ff03f | ||
|
|
a2d2dac2b9 | ||
|
|
46f8eb5308 | ||
|
|
907542b651 | ||
|
|
5ab4021100 | ||
|
|
73cd5faab0 | ||
|
|
0ac7ac4343 | ||
|
|
92734dd251 | ||
|
|
08c8b7d09c | ||
|
|
914ad0edf8 | ||
|
|
cfd416002d | ||
|
|
349de76b6b | ||
|
|
9345e18259 | ||
|
|
360654fe58 | ||
|
|
253ce8d16c | ||
|
|
78b08758f1 | ||
|
|
9470ce74c1 | ||
|
|
cb0bcdb94c | ||
|
|
5580e8a8b7 | ||
|
|
212f2f86fc | ||
|
|
e6fefb2510 | ||
|
|
5b10ab4823 | ||
|
|
f021f56318 | ||
|
|
d5521ea681 | ||
|
|
7781d6c849 | ||
|
|
4227024fba | ||
|
|
7243f4444f | ||
|
|
c8d24998e8 | ||
|
|
f0b7152c41 | ||
|
|
8d6a20d375 | ||
|
|
8413093d43 | ||
|
|
1a8a1736bf | ||
|
|
51c7d172b3 | ||
|
|
6999ae5680 | ||
|
|
0c3f74e7de | ||
|
|
d2703ff84b | ||
|
|
1a9e0825fc | ||
|
|
284a861927 | ||
|
|
02b4e6b5b4 | ||
|
|
60084c292e | ||
|
|
fa22a080b9 | ||
|
|
70f0af98f6 | ||
|
|
cd4df316ae | ||
|
|
ff55dce746 | ||
|
|
5a86c03f74 | ||
|
|
1c2df46ac4 | ||
|
|
8839620e63 | ||
|
|
c03126e8f8 | ||
|
|
10ef36859d | ||
|
|
4acb75f2bd | ||
|
|
77e2fb2537 | ||
|
|
c7bbb97a68 | ||
|
|
6d1c360c02 | ||
|
|
3b73eb3bd1 | ||
|
|
6ffcc056ad | ||
|
|
742f680d0d | ||
|
|
c872f1c6b0 | ||
|
|
bc5a40f143 | ||
|
|
c2bd3f6273 | ||
|
|
343dee3576 | ||
|
|
ebcafd3c6d | ||
|
|
5231b5ca4b | ||
|
|
1195456bee | ||
|
|
48de6b9821 | ||
|
|
cd4a17fe31 | ||
|
|
d3a5b3e6ef | ||
|
|
3c4c6c7389 | ||
|
|
876f728aa2 | ||
|
|
950a6dabd8 | ||
|
|
1d9589a186 | ||
|
|
b13fa7dc05 | ||
|
|
069f6c3ec7 | ||
|
|
5a27b79b51 | ||
|
|
72453c80bf | ||
|
|
14800ffb1e | ||
|
|
e2f36d01bc | ||
|
|
55b231b456 | ||
|
|
b4b2607df1 | ||
|
|
ac9ba4776c | ||
|
|
85aca0d022 | ||
|
|
80c74b2d1a | ||
|
|
d28f224ad5 | ||
|
|
f2a808ed13 | ||
|
|
4ef420651d | ||
|
|
65ce66a541 | ||
|
|
deae53b721 | ||
|
|
f459e83861 | ||
|
|
badab99242 | ||
|
|
84124ba1b1 | ||
|
|
2ad0d2072d | ||
|
|
ff1632dcda | ||
|
|
531b8c1d09 | ||
|
|
a8128cef8d | ||
|
|
3baffb2a69 | ||
|
|
06bdf999a6 | ||
|
|
76ff1f4d4f | ||
|
|
2360b4147c | ||
|
|
37874ff58e | ||
|
|
aef13155fc | ||
|
|
1d4f104524 | ||
|
|
11ec4b4816 | ||
|
|
2bd899848d | ||
|
|
18a6e8d24c | ||
|
|
13c686a8a1 | ||
|
|
7a172c0306 | ||
|
|
7fc04fcf20 | ||
|
|
a40ea61415 | ||
|
|
eba517d34d | ||
|
|
38257492bd | ||
|
|
93592c984d | ||
|
|
7a08bc0b2b | ||
|
|
25e8cac613 | ||
|
|
02eaea85d8 | ||
|
|
6c433d642d | ||
|
|
7aed3e09e8 | ||
|
|
e0e6ab0de6 | ||
|
|
7a1cd8a6f6 | ||
|
|
9407d500c8 | ||
|
|
9f1dd7def1 | ||
|
|
480f188d86 | ||
|
|
e2bd366bb3 | ||
|
|
f80c8a0481 | ||
|
|
7e996fffa1 | ||
|
|
d14e25c29f | ||
|
|
1ed7ab9776 | ||
|
|
dd8867b52f | ||
|
|
3668eb2829 | ||
|
|
e751dfc1b2 | ||
|
|
6c3bbbf72b | ||
|
|
9dcb45a017 | ||
|
|
b9069433b1 | ||
|
|
739f6a08da | ||
|
|
2fc8b64964 | ||
|
|
6e133b6b59 | ||
|
|
01e3e02a62 | ||
|
|
85af70e2ee | ||
|
|
b21d9bef87 | ||
|
|
26b89dae76 | ||
|
|
b2a2ef70a4 | ||
|
|
8286e00eb3 | ||
|
|
c7487c9763 | ||
|
|
e910d0a8a7 | ||
|
|
a3b9608887 | ||
|
|
09002cfe22 |
@@ -6,3 +6,6 @@ role-state.nix
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
iso/secrets/enroll-token
|
||||
iso/secrets/provisioner-url
|
||||
result
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -1,66 +1,259 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg version="1.1" viewBox="0 0 256 256" id="svg383" sodipodi:docname="vaultwarden-icon.svg" inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)" width="256" height="256" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs id="defs387" />
|
||||
<sodipodi:namedview id="namedview385" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" showgrid="false" inkscape:zoom="3.3359375" inkscape:cx="128" inkscape:cy="128" inkscape:window-width="1874" inkscape:window-height="1056" inkscape:window-x="46" inkscape:window-y="24" inkscape:window-maximized="1" inkscape:current-layer="svg383" />
|
||||
<title id="title287">Vaultwarden Icon</title>
|
||||
<g id="logo" transform="matrix(2.4381018,0,0,2.4381018,128,128)">
|
||||
<g id="gear" mask="url(#holes)">
|
||||
<path d="m-31.1718-33.813208 26.496029 74.188883h9.3515399l26.49603-74.188883h-9.767164l-16.728866 47.588948q-1.662496 4.571864-2.805462 8.624198-1.142966 3.948427-1.870308 7.585137-.72734199-3.63671-1.8703079-7.689043-1.142966-4.052334-2.805462-8.728104l-16.624959-47.381136z" stroke="#000" stroke-width="4.51171" id="path289" />
|
||||
<circle transform="scale(-1,1)" r="43" fill="none" stroke="#000" stroke-width="9" id="circle291" />
|
||||
<g id="cogs" transform="scale(-1,1)">
|
||||
<polygon id="cog" points="51 0 46 -3 46 3" stroke="#000" stroke-linejoin="round" stroke-width="3" />
|
||||
<use transform="rotate(11.25)" xlink:href="#cog" id="use294" />
|
||||
<use transform="rotate(22.5)" xlink:href="#cog" id="use296" />
|
||||
<use transform="rotate(33.75)" xlink:href="#cog" id="use298" />
|
||||
<use transform="rotate(45)" xlink:href="#cog" id="use300" />
|
||||
<use transform="rotate(56.25)" xlink:href="#cog" id="use302" />
|
||||
<use transform="rotate(67.5)" xlink:href="#cog" id="use304" />
|
||||
<use transform="rotate(78.75)" xlink:href="#cog" id="use306" />
|
||||
<use transform="rotate(90)" xlink:href="#cog" id="use308" />
|
||||
<use transform="rotate(101.25)" xlink:href="#cog" id="use310" />
|
||||
<use transform="rotate(112.5)" xlink:href="#cog" id="use312" />
|
||||
<use transform="rotate(123.75)" xlink:href="#cog" id="use314" />
|
||||
<use transform="rotate(135)" xlink:href="#cog" id="use316" />
|
||||
<use transform="rotate(146.25)" xlink:href="#cog" id="use318" />
|
||||
<use transform="rotate(157.5)" xlink:href="#cog" id="use320" />
|
||||
<use transform="rotate(168.75)" xlink:href="#cog" id="use322" />
|
||||
<use transform="scale(-1)" xlink:href="#cog" id="use324" />
|
||||
<use transform="rotate(191.25)" xlink:href="#cog" id="use326" />
|
||||
<use transform="rotate(202.5)" xlink:href="#cog" id="use328" />
|
||||
<use transform="rotate(213.75)" xlink:href="#cog" id="use330" />
|
||||
<use transform="rotate(225)" xlink:href="#cog" id="use332" />
|
||||
<use transform="rotate(236.25)" xlink:href="#cog" id="use334" />
|
||||
<use transform="rotate(247.5)" xlink:href="#cog" id="use336" />
|
||||
<use transform="rotate(258.75)" xlink:href="#cog" id="use338" />
|
||||
<use transform="rotate(-90)" xlink:href="#cog" id="use340" />
|
||||
<use transform="rotate(-78.75)" xlink:href="#cog" id="use342" />
|
||||
<use transform="rotate(-67.5)" xlink:href="#cog" id="use344" />
|
||||
<use transform="rotate(-56.25)" xlink:href="#cog" id="use346" />
|
||||
<use transform="rotate(-45)" xlink:href="#cog" id="use348" />
|
||||
<use transform="rotate(-33.75)" xlink:href="#cog" id="use350" />
|
||||
<use transform="rotate(-22.5)" xlink:href="#cog" id="use352" />
|
||||
<use transform="rotate(-11.25)" xlink:href="#cog" id="use354" />
|
||||
</g>
|
||||
<g id="mounts" transform="scale(-1,1)">
|
||||
<polygon id="mount" points="0 -35 7 -42 -7 -42" stroke="#000" stroke-linejoin="round" stroke-width="6" />
|
||||
<use transform="rotate(72)" xlink:href="#mount" id="use358" />
|
||||
<use transform="rotate(144)" xlink:href="#mount" id="use360" />
|
||||
<use transform="rotate(216)" xlink:href="#mount" id="use362" />
|
||||
<use transform="rotate(-72)" xlink:href="#mount" id="use364" />
|
||||
</g>
|
||||
</g>
|
||||
<mask id="holes">
|
||||
<rect x="-60" y="-60" width="120" height="120" fill="#fff" id="rect368" />
|
||||
<circle id="hole" cy="-40" r="3" />
|
||||
<use transform="rotate(72)" xlink:href="#hole" id="use371" />
|
||||
<use transform="rotate(144)" xlink:href="#hole" id="use373" />
|
||||
<use transform="rotate(216)" xlink:href="#hole" id="use375" />
|
||||
<use transform="rotate(-72)" xlink:href="#hole" id="use377" />
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 256 256"
|
||||
id="svg383"
|
||||
sodipodi:docname="vaultwarden.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
width="256"
|
||||
height="256"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs387">
|
||||
<mask
|
||||
id="holes">
|
||||
<rect
|
||||
x="-60"
|
||||
y="-60"
|
||||
width="120"
|
||||
height="120"
|
||||
fill="#fff"
|
||||
id="rect368" />
|
||||
<circle
|
||||
id="hole"
|
||||
cy="-40"
|
||||
r="3" />
|
||||
<use
|
||||
transform="rotate(72)"
|
||||
xlink:href="#hole"
|
||||
id="use371" />
|
||||
<use
|
||||
transform="rotate(144)"
|
||||
xlink:href="#hole"
|
||||
id="use373" />
|
||||
<use
|
||||
transform="rotate(216)"
|
||||
xlink:href="#hole"
|
||||
id="use375" />
|
||||
<use
|
||||
transform="rotate(-72)"
|
||||
xlink:href="#hole"
|
||||
id="use377" />
|
||||
</mask>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="namedview385"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="3.3359375"
|
||||
inkscape:cx="128.14988"
|
||||
inkscape:cy="127.85012"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1363"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="gear" />
|
||||
<title
|
||||
id="title287">Vaultwarden Icon</title>
|
||||
<g
|
||||
id="gear"
|
||||
mask="url(#holes)"
|
||||
transform="matrix(2.4381018,0,0,2.4381018,128,128)">
|
||||
<path
|
||||
d="M -31.1718,-33.813208 -4.675771,40.375675 H 4.6757689 L 31.171799,-33.813208 H 21.404635 L 4.6757689,13.77574 q -1.662496,4.571864 -2.805462,8.624198 -1.142966,3.948427 -1.870308,7.585137 -0.72734199,-3.63671 -1.8703079,-7.689043 -1.142966,-4.052334 -2.805462,-8.728104 L -21.30073,-33.813208 Z"
|
||||
stroke="#ffffff"
|
||||
stroke-width="4.51171"
|
||||
id="path289"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<circle
|
||||
transform="scale(-1,1)"
|
||||
r="43"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="9"
|
||||
id="circle291"
|
||||
cx="0"
|
||||
cy="0" />
|
||||
<g
|
||||
id="cogs"
|
||||
transform="scale(-1,1)">
|
||||
<polygon
|
||||
id="cog"
|
||||
points="46,3 51,0 46,-3 "
|
||||
stroke="#ffffff"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3" />
|
||||
<use
|
||||
transform="rotate(11.25)"
|
||||
xlink:href="#cog"
|
||||
id="use294" />
|
||||
<use
|
||||
transform="rotate(22.5)"
|
||||
xlink:href="#cog"
|
||||
id="use296" />
|
||||
<use
|
||||
transform="rotate(33.75)"
|
||||
xlink:href="#cog"
|
||||
id="use298" />
|
||||
<use
|
||||
transform="rotate(45)"
|
||||
xlink:href="#cog"
|
||||
id="use300" />
|
||||
<use
|
||||
transform="rotate(56.25)"
|
||||
xlink:href="#cog"
|
||||
id="use302" />
|
||||
<use
|
||||
transform="rotate(67.5)"
|
||||
xlink:href="#cog"
|
||||
id="use304" />
|
||||
<use
|
||||
transform="rotate(78.75)"
|
||||
xlink:href="#cog"
|
||||
id="use306" />
|
||||
<use
|
||||
transform="rotate(90)"
|
||||
xlink:href="#cog"
|
||||
id="use308" />
|
||||
<use
|
||||
transform="rotate(101.25)"
|
||||
xlink:href="#cog"
|
||||
id="use310" />
|
||||
<use
|
||||
transform="rotate(112.5)"
|
||||
xlink:href="#cog"
|
||||
id="use312" />
|
||||
<use
|
||||
transform="rotate(123.75)"
|
||||
xlink:href="#cog"
|
||||
id="use314" />
|
||||
<use
|
||||
transform="rotate(135)"
|
||||
xlink:href="#cog"
|
||||
id="use316" />
|
||||
<use
|
||||
transform="rotate(146.25)"
|
||||
xlink:href="#cog"
|
||||
id="use318" />
|
||||
<use
|
||||
transform="rotate(157.5)"
|
||||
xlink:href="#cog"
|
||||
id="use320" />
|
||||
<use
|
||||
transform="rotate(168.75)"
|
||||
xlink:href="#cog"
|
||||
id="use322" />
|
||||
<use
|
||||
transform="scale(-1)"
|
||||
xlink:href="#cog"
|
||||
id="use324" />
|
||||
<use
|
||||
transform="rotate(-168.75)"
|
||||
xlink:href="#cog"
|
||||
id="use326" />
|
||||
<use
|
||||
transform="rotate(-157.5)"
|
||||
xlink:href="#cog"
|
||||
id="use328" />
|
||||
<use
|
||||
transform="rotate(-146.25)"
|
||||
xlink:href="#cog"
|
||||
id="use330" />
|
||||
<use
|
||||
transform="rotate(-135)"
|
||||
xlink:href="#cog"
|
||||
id="use332" />
|
||||
<use
|
||||
transform="rotate(-123.75)"
|
||||
xlink:href="#cog"
|
||||
id="use334" />
|
||||
<use
|
||||
transform="rotate(-112.5)"
|
||||
xlink:href="#cog"
|
||||
id="use336" />
|
||||
<use
|
||||
transform="rotate(-101.25)"
|
||||
xlink:href="#cog"
|
||||
id="use338" />
|
||||
<use
|
||||
transform="rotate(-90)"
|
||||
xlink:href="#cog"
|
||||
id="use340" />
|
||||
<use
|
||||
transform="rotate(-78.75)"
|
||||
xlink:href="#cog"
|
||||
id="use342" />
|
||||
<use
|
||||
transform="rotate(-67.5)"
|
||||
xlink:href="#cog"
|
||||
id="use344" />
|
||||
<use
|
||||
transform="rotate(-56.25)"
|
||||
xlink:href="#cog"
|
||||
id="use346" />
|
||||
<use
|
||||
transform="rotate(-45)"
|
||||
xlink:href="#cog"
|
||||
id="use348" />
|
||||
<use
|
||||
transform="rotate(-33.75)"
|
||||
xlink:href="#cog"
|
||||
id="use350" />
|
||||
<use
|
||||
transform="rotate(-22.5)"
|
||||
xlink:href="#cog"
|
||||
id="use352" />
|
||||
<use
|
||||
transform="rotate(-11.25)"
|
||||
xlink:href="#cog"
|
||||
id="use354" />
|
||||
</g>
|
||||
<g
|
||||
id="mounts"
|
||||
transform="scale(-1,1)">
|
||||
<polygon
|
||||
id="mount"
|
||||
points="-7,-42 0,-35 7,-42 "
|
||||
stroke="#ffffff"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="6" />
|
||||
<use
|
||||
transform="rotate(72)"
|
||||
xlink:href="#mount"
|
||||
id="use358" />
|
||||
<use
|
||||
transform="rotate(144)"
|
||||
xlink:href="#mount"
|
||||
id="use360" />
|
||||
<use
|
||||
transform="rotate(-144)"
|
||||
xlink:href="#mount"
|
||||
id="use362" />
|
||||
<use
|
||||
transform="rotate(-72)"
|
||||
xlink:href="#mount"
|
||||
id="use364" />
|
||||
</g>
|
||||
</g>
|
||||
<metadata id="metadata381">
|
||||
<metadata
|
||||
id="metadata381">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:title>Vaultwarden Icon</dc:title>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -239,33 +239,13 @@ mkdir -p "$BACKUP_DIR/secrets"
|
||||
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
||||
# /var/lib/domains is still backed up if present (hub state)
|
||||
for SRC in /var/lib/domains; do
|
||||
if [[ -e "$SRC" ]]; then
|
||||
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy $SRC — continuing."
|
||||
else
|
||||
log " (not found: $SRC — skipping)"
|
||||
fi
|
||||
done
|
||||
else
|
||||
for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do
|
||||
if [[ -e "$SRC" ]]; then
|
||||
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy $SRC — continuing."
|
||||
else
|
||||
log " (not found: $SRC — skipping)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Hub state files from /var/lib/secrets/ (backed up for all roles)
|
||||
if [[ -d /var/lib/secrets ]]; then
|
||||
mkdir -p "$BACKUP_DIR/secrets/hub-state"
|
||||
rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy /var/lib/secrets — continuing."
|
||||
else
|
||||
log " (not found: /var/lib/secrets — skipping)"
|
||||
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||
rsync -a --info=progress2 /etc/nix-bitcoin-secrets "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy /etc/nix-bitcoin-secrets — continuing."
|
||||
else
|
||||
log " (not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Stage 2 complete."
|
||||
@@ -286,20 +266,35 @@ else
|
||||
log "WARNING: /home not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 4/4: Wallet and node data ─────────────────────────────
|
||||
# ── Stage 4/4: System data ───────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────"
|
||||
log "── Stage 4/4: System data (/var/lib) ────────────────────────"
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "Skipping Stage 4 (LND wallet data) — not applicable for Desktop Only role."
|
||||
elif [[ -d /var/lib/lnd ]]; then
|
||||
if [[ -d /var/lib ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--filter='- /lnd/***' \
|
||||
--exclude='logs/' \
|
||||
--exclude='log/' \
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/log/' \
|
||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib for Desktop Only role"
|
||||
log "Stage 4 complete (Desktop Only role excludes /var/lib/lnd)."
|
||||
else
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
elif [[ -d /var/lib ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--exclude='logs/' \
|
||||
/var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib/lnd"
|
||||
--exclude='log/' \
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/log/' \
|
||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib"
|
||||
log "Stage 4 complete."
|
||||
else
|
||||
log "WARNING: /var/lib/lnd not found — skipping."
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Generate manifest ────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||
Dark theme matching the Adwaita dark aesthetic
|
||||
v6 — Status-only tiles (no controls) */
|
||||
Dark theme — near-black with green accents matching the Sovran Hub icon
|
||||
v8 — Black-forward, green used for accents/borders/highlights only */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
@@ -9,22 +9,22 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #1e1e2e;
|
||||
--surface-color: #2a2a3c;
|
||||
--card-color: #313244;
|
||||
--border-color: #45475a;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--text-dim: #6c7086;
|
||||
--accent-color: #89b4fa;
|
||||
--green: #2ec27e;
|
||||
--bg-color: #080a09;
|
||||
--surface-color: rgba(14, 16, 15, 0.7);
|
||||
--card-color: rgba(20, 22, 21, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.06);
|
||||
--text-primary: #ecf3ef;
|
||||
--text-secondary: #8aaa9a;
|
||||
--text-dim: #4a6658;
|
||||
--accent-color: #5EAD8A;
|
||||
--green: #6DBF8B;
|
||||
--yellow: #e5a50a;
|
||||
--red: #e01b24;
|
||||
--grey: #888888;
|
||||
--grey: #5E7A6A;
|
||||
--radius-card: 18px;
|
||||
--radius-btn: 8px;
|
||||
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
|
||||
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -32,8 +32,10 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
@@ -53,8 +55,10 @@ body {
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(14, 16, 15, 0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
@@ -112,7 +116,7 @@ body {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -22,17 +22,17 @@ button:disabled {
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: BLUE by default */
|
||||
/* Update System button: uses accent green by default */
|
||||
.btn-update {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
background-color: #4A9474;
|
||||
color: #E0F2EA;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -43,14 +43,14 @@ button:disabled {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: GREEN when updates are available */
|
||||
/* Update System button: brighter green when updates are available */
|
||||
.btn-update.has-updates {
|
||||
background-color: #2ec27e;
|
||||
color: #fff;
|
||||
background-color: #5EAD8A;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-update.has-updates:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
background-color: #78C8A2;
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
|
||||
@@ -18,7 +18,7 @@ domain-field-label {
|
||||
|
||||
domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #12121c;
|
||||
background-color: #0c0f0e;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -75,7 +75,7 @@ domain-field-actions {
|
||||
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #12121c;
|
||||
background-color: #0c0f0e;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -145,7 +145,7 @@ domain-field-actions {
|
||||
|
||||
.port-req-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.5);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||
|
||||
.header-bar {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 24px;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
background-color: rgba(10, 12, 11, 0.82);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 140px;
|
||||
height: 80px;
|
||||
width: auto;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-bar .title {
|
||||
@@ -33,7 +37,7 @@
|
||||
|
||||
.role-badge {
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
@@ -44,8 +48,10 @@
|
||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||
|
||||
.ip-bar {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: rgba(10, 12, 11, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -68,4 +74,47 @@
|
||||
|
||||
.ip-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
}
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── Header reboot button ───────────────────────────────────────── */
|
||||
|
||||
.btn-header-reboot {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(184, 125, 0, 0.35);
|
||||
color: #c98d08;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-header-reboot:hover {
|
||||
border-color: #b87d00;
|
||||
color: #e0a010;
|
||||
background-color: rgba(184, 125, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.btn-header-reboot {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: var(--surface-color);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background-color: rgba(12, 14, 13, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -46,7 +48,7 @@
|
||||
.sidebar-support-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
background-color: #35354a;
|
||||
background-color: #162320;
|
||||
}
|
||||
|
||||
.sidebar-support-btn + .sidebar-support-btn {
|
||||
@@ -82,23 +84,6 @@
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar: Upgrade button (Node role) ────────────────────────── */
|
||||
|
||||
.sidebar-upgrade-btn {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(137, 180, 250, 0.06);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sidebar-upgrade-btn:hover {
|
||||
background-color: rgba(137, 180, 250, 0.14);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.sidebar-upgrade-btn .sidebar-support-hint {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── Upgrade modal ──────────────────────────────────────────────── */
|
||||
|
||||
.upgrade-dialog {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -15,15 +17,17 @@
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(14, 16, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -71,7 +75,7 @@
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: #12121c;
|
||||
background-color: #0c0f0e;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-height: 200px;
|
||||
@@ -89,18 +93,60 @@
|
||||
/* Reboot = GREEN */
|
||||
.modal-footer .btn-reboot,
|
||||
button.btn-reboot {
|
||||
background-color: #2ec27e;
|
||||
background-color: #6DBF8B;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-footer .btn-reboot:hover:not(:disabled),
|
||||
button.btn-reboot:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
background-color: #529E7E;
|
||||
}
|
||||
|
||||
/* Restart = AMBER (manual restart action) */
|
||||
.btn-restart-amber {
|
||||
background-color: #b87d00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-restart-amber:hover:not(:disabled) {
|
||||
background-color: #9a6800;
|
||||
}
|
||||
|
||||
/* Restart conflict warning box */
|
||||
.restart-conflict-box {
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.restart-conflict-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #e69000;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.restart-conflict-desc {
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Reboot error card actions row */
|
||||
.reboot-error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: var(--yellow);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
@@ -113,21 +159,23 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.btn-close-modal:hover:not(:disabled) {
|
||||
background-color: #5a5c72;
|
||||
background-color: #1c2a24;
|
||||
}
|
||||
|
||||
/* ── Credentials info modal ──────────────────────────────────────── */
|
||||
|
||||
.creds-dialog {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(14, 16, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 700px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
animation: creds-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -147,6 +195,17 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.creds-title-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creds-close-btn {
|
||||
@@ -203,7 +262,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
background-color: #12121c;
|
||||
background-color: #0c0f0e;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
@@ -228,7 +287,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.creds-copy-btn:hover {
|
||||
background-color: #5a5c72;
|
||||
background-color: #1c2a24;
|
||||
}
|
||||
|
||||
.creds-copy-btn.copied {
|
||||
@@ -272,7 +331,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
|
||||
.matrix-action-btn {
|
||||
background-color: var(--accent-color);
|
||||
color: #0f0f19;
|
||||
color: #0A1A10;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
padding: 10px 18px;
|
||||
@@ -284,7 +343,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.matrix-action-btn:hover {
|
||||
background-color: #a8c8ff;
|
||||
background-color: #7CC4A0;
|
||||
}
|
||||
|
||||
.matrix-form-group {
|
||||
@@ -301,7 +360,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
|
||||
.matrix-form-input {
|
||||
width: 100%;
|
||||
background-color: #12121c;
|
||||
background-color: #0c0f0e;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -336,7 +395,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
|
||||
.matrix-form-submit {
|
||||
background-color: var(--accent-color);
|
||||
color: #0f0f19;
|
||||
color: #0A1A10;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
padding: 10px 20px;
|
||||
@@ -347,7 +406,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.matrix-form-submit:hover:not(:disabled) {
|
||||
background-color: #a8c8ff;
|
||||
background-color: #7CC4A0;
|
||||
}
|
||||
|
||||
.matrix-form-submit:disabled {
|
||||
@@ -367,7 +426,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.matrix-form-back:hover {
|
||||
background-color: #5a5c72;
|
||||
background-color: #1c2a24;
|
||||
}
|
||||
|
||||
.matrix-form-result {
|
||||
@@ -380,7 +439,7 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.matrix-form-result.success {
|
||||
background-color: rgba(74, 222, 128, 0.12);
|
||||
background-color: rgba(109, 191, 139, 0.12);
|
||||
border: 1px solid var(--green);
|
||||
color: var(--green);
|
||||
display: block;
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color);
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
padding: 24px 16px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -68,13 +70,13 @@
|
||||
.onboarding-step-dot.active {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.completed {
|
||||
background-color: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.onboarding-step-connector {
|
||||
@@ -137,8 +139,10 @@
|
||||
/* Cards */
|
||||
|
||||
.onboarding-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(14, 16, 15, 0.65);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
@@ -146,17 +150,6 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.onboarding-card--scroll {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.onboarding-card--ports {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Body text */
|
||||
|
||||
.onboarding-body-text {
|
||||
@@ -191,10 +184,10 @@
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(137, 180, 250, 0.3);
|
||||
border: 1px solid rgba(94, 173, 138, 0.25);
|
||||
}
|
||||
|
||||
/* Step header */
|
||||
@@ -228,7 +221,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 4px;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.onboarding-btn-next {
|
||||
@@ -278,7 +273,7 @@
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -356,7 +351,7 @@
|
||||
|
||||
.onboarding-port-table td {
|
||||
padding: 8px 8px;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.5);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -414,8 +409,8 @@
|
||||
|
||||
.onboarding-creds-notice {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(137, 180, 250, 0.08);
|
||||
border: 1px solid rgba(137, 180, 250, 0.25);
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -508,8 +503,8 @@
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(137, 180, 250, 0.1);
|
||||
border: 1px solid rgba(137, 180, 250, 0.25);
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -517,7 +512,7 @@
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn:hover {
|
||||
background-color: rgba(137, 180, 250, 0.2);
|
||||
background-color: rgba(94, 173, 138, 0.15);
|
||||
}
|
||||
|
||||
/* Completion checklist (Step 5) */
|
||||
@@ -575,13 +570,174 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Timezone / Locale step (Step 2) ────────────────────────────── */
|
||||
|
||||
.onboarding-tz-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.onboarding-tz-search {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-tz-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-tz-select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.onboarding-tz-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-tz-select option {
|
||||
padding: 4px 8px;
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.onboarding-locale-select {
|
||||
height: auto;
|
||||
size: auto;
|
||||
}
|
||||
|
||||
/* ── Password step (Step 3) ─────────────────────────────────────── */
|
||||
|
||||
.onboarding-password-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.onboarding-password-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.onboarding-password-input {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-password-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-password-toggle {
|
||||
padding: 6px 10px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-password-toggle:hover {
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-password-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.onboarding-password-warning {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
line-height: 1.5;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.onboarding-password-success {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
border: 1px solid rgba(94, 173, 138, 0.30);
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--green);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-password-optional {
|
||||
margin-top: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary::before {
|
||||
content: '▶ ';
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.onboarding-password-optional[open] > summary::before {
|
||||
content: '▼ ';
|
||||
}
|
||||
|
||||
/* ── Reboot overlay ─────────────────────────────────────────────── */
|
||||
|
||||
.reboot-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(15, 15, 25, 0.92);
|
||||
background-color: rgba(6, 8, 7, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -592,8 +748,10 @@
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(14, 16, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/* ── Security Modal sections ──────────────────────────────────────── */
|
||||
|
||||
.security-section {
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.security-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.security-section-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.security-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
/* ── Security Reset warning box ──────────────────────────────────── */
|
||||
|
||||
.security-warning-box {
|
||||
background-color: rgba(180, 40, 40, 0.10);
|
||||
border-left: 3px solid #c94040;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.security-warning-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.security-warning-list {
|
||||
margin: 6px 0 6px 20px;
|
||||
padding: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.security-erase-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.security-erase-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.security-erase-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg, var(--card-color));
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.security-reset-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #c94040;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #a83030;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.security-status-msg {
|
||||
font-size: 0.83rem;
|
||||
margin-top: 10px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.security-status-info { color: var(--text-secondary); }
|
||||
.security-status-ok { color: #6DBF8B; }
|
||||
.security-status-error { color: #e05252; }
|
||||
|
||||
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||
|
||||
.security-verify-list {
|
||||
margin: 8px 0 10px 20px;
|
||||
padding: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.security-verify-loading {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-verify-result-card {
|
||||
background: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.security-verify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.security-verify-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 130px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.security-verify-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-verify-mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.security-verify-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-color, #6DBF8B);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.security-verify-link:hover { text-decoration: underline; }
|
||||
|
||||
.security-verify-badge {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.security-verify-pass {
|
||||
background-color: rgba(109, 191, 139, 0.15);
|
||||
color: #6DBF8B;
|
||||
}
|
||||
|
||||
.security-verify-fail {
|
||||
background-color: rgba(224, 82, 82, 0.15);
|
||||
color: #e05252;
|
||||
}
|
||||
|
||||
.security-verify-errors {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-verify-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 0.75rem;
|
||||
background: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.security-verify-path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.security-verify-path-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* ── Security Reset full-screen overlay ──────────────────────────── */
|
||||
|
||||
.security-reset-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(6, 8, 7, 0.94);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: security-reset-fade-in 0.35s ease-out;
|
||||
}
|
||||
|
||||
.security-reset-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes security-reset-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.security-reset-overlay-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Phase 2: password display box ──────────────────────────────── */
|
||||
|
||||
.security-reset-password-label {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.security-reset-password-box {
|
||||
font-family: monospace;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
background: rgba(109, 191, 139, 0.10);
|
||||
border: 1.5px solid rgba(109, 191, 139, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 14px 24px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
margin-bottom: 16px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.security-reset-password-warning {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn {
|
||||
background-color: #6DBF8B;
|
||||
color: #0a0c0b;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 11px 22px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:hover:not(:disabled) {
|
||||
background-color: #5aab78;
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── First-login security banner ─────────────────────────────────── */
|
||||
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border-bottom: 2px solid rgba(94, 173, 138, 0.25);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-banner-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.security-banner-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.security-banner-text {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-banner-dismiss {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 7px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.security-banner-dismiss:hover {
|
||||
background-color: rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||
|
||||
.security-inline-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-inline-icon {
|
||||
font-size: 1rem;
|
||||
color: #e69000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.security-inline-text {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-inline-link {
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #e69000;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c97a00;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-start;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.security-inline-link:hover {
|
||||
background-color: rgba(180, 100, 0, 0.22);
|
||||
}
|
||||
|
||||
/* ── System change-password form extras ──────────────────────────── */
|
||||
|
||||
.sys-chpw-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sys-chpw-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sys-chpw-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pw-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pw-input-wrap .matrix-form-input {
|
||||
padding-right: 2.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pw-toggle-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.pw-toggle-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pw-hint {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pw-credentials-note {
|
||||
font-size: 0.78rem;
|
||||
color: #c97a00;
|
||||
background-color: rgba(180, 100, 0, 0.10);
|
||||
border-left: 2px solid #c97a00;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.support-steps code {
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
@@ -116,7 +116,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
@@ -146,7 +146,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 16px;
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
.support-btn-auditlog:hover:not(:disabled) {
|
||||
background-color: #5a5c72;
|
||||
background-color: #1c2a24;
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
@@ -219,8 +219,8 @@
|
||||
}
|
||||
|
||||
.support-wallet-protected {
|
||||
background-color: rgba(46, 194, 126, 0.06);
|
||||
border-color: rgba(46, 194, 126, 0.3);
|
||||
background-color: rgba(109, 191, 139, 0.06);
|
||||
border-color: rgba(109, 191, 139, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-unlocked {
|
||||
@@ -290,7 +290,7 @@
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--yellow);
|
||||
color: #1e1e2e;
|
||||
color: #0A1A10;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -310,7 +310,7 @@
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
background-color: #529E7E;
|
||||
}
|
||||
|
||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||
@@ -324,7 +324,7 @@
|
||||
.support-audit-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #12121c;
|
||||
background-color: #0c0f0e;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
@@ -334,7 +334,7 @@
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.3);
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.4);
|
||||
}
|
||||
|
||||
.support-audit-entry:last-child {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
min-height: 130px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
@@ -20,7 +22,7 @@
|
||||
|
||||
.service-tile:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: #6c7086;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.service-tile.disabled {
|
||||
@@ -93,6 +95,7 @@
|
||||
.status-dot.disabled { background-color: var(--grey); }
|
||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
||||
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
|
||||
|
||||
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
|
||||
|
||||
@@ -152,6 +155,69 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
|
||||
|
||||
.tile-bip110-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tile-bip110-badge--active {
|
||||
background: rgba(109, 191, 139, 0.18);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(109, 191, 139, 0.3);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--locked_in {
|
||||
background: rgba(94, 173, 138, 0.15);
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(94, 173, 138, 0.3);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--signaling {
|
||||
background: rgba(94, 173, 138, 0.12);
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(94, 173, 138, 0.2);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--not_signaling {
|
||||
background: rgba(229, 165, 10, 0.12);
|
||||
color: var(--yellow);
|
||||
border: 1px solid rgba(229, 165, 10, 0.25);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--unsupported {
|
||||
background: rgba(94, 122, 106, 0.12);
|
||||
color: var(--grey);
|
||||
border: 1px solid rgba(94, 122, 106, 0.2);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--unknown {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bip110-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bip110-source-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Service detail modal sections ───────────────────────────────── */
|
||||
|
||||
.svc-detail-section {
|
||||
@@ -235,7 +301,7 @@
|
||||
|
||||
.svc-detail-port-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.6);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -294,7 +360,7 @@
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot code {
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
@@ -310,6 +376,12 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Service detail: Domain configure button ─────────────────────── */
|
||||
|
||||
.svc-detail-domain-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
||||
|
||||
.svc-detail-addon-row {
|
||||
@@ -343,3 +415,59 @@
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #d97706;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background-color: #b45309;
|
||||
}
|
||||
|
||||
.svc-detail-restart-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.svc-detail-restart-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.svc-detail-restart-result {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.svc-detail-restart-result.success {
|
||||
background-color: rgba(109, 191, 139, 0.12);
|
||||
border: 1px solid var(--green);
|
||||
color: var(--green);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.svc-detail-restart-result.error {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid #ef4444;
|
||||
color: #f87171;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* ── Desktop launch buttons ──────────────────────────────────────── */
|
||||
|
||||
.svc-detail-launch-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.svc-detail-launch-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ const POLL_INTERVAL_SERVICES = 5000;
|
||||
const POLL_INTERVAL_UPDATES = 1800000;
|
||||
const UPDATE_POLL_INTERVAL = 2000;
|
||||
const REBOOT_CHECK_INTERVAL = 5000;
|
||||
const REBOOT_FETCH_TIMEOUT = 12000;
|
||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||
const REBOOT_INITIAL_DELAY = 25000;
|
||||
const SUPPORT_TIMER_INTERVAL = 1000;
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
|
||||
@@ -9,6 +9,12 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||
|
||||
// Logout button
|
||||
if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
|
||||
fetch("/api/logout", { method: "POST", credentials: "same-origin" })
|
||||
.finally(function () { window.location.replace("/login"); });
|
||||
});
|
||||
|
||||
// Rebuild modal
|
||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||
@@ -38,6 +44,28 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
|
||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
||||
|
||||
// Restart confirm dialog
|
||||
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
|
||||
|
||||
// Header Reboot button
|
||||
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
|
||||
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
|
||||
if ($restartConfirmOk.disabled) return;
|
||||
$restartConfirmOk.disabled = true;
|
||||
closeRestartConfirmDialog();
|
||||
doReboot();
|
||||
});
|
||||
|
||||
// Reboot error card buttons
|
||||
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
|
||||
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
|
||||
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
|
||||
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
|
||||
});
|
||||
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
|
||||
|
||||
// ── Upgrade modal functions ───────────────────────────────────────
|
||||
|
||||
function openUpgradeModal() {
|
||||
@@ -48,18 +76,63 @@ function closeUpgradeModal() {
|
||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Restart confirm dialog functions ─────────────────────────────
|
||||
|
||||
var _restartDialogOpener = null;
|
||||
|
||||
function openRestartConfirmDialog() {
|
||||
if (!$restartConfirmModal) return;
|
||||
_restartDialogOpener = document.activeElement;
|
||||
|
||||
// Detect conflicting operations
|
||||
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
|
||||
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
|
||||
|
||||
$restartConfirmModal.classList.add("open");
|
||||
|
||||
// Focus Cancel initially for safety
|
||||
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
|
||||
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
|
||||
}
|
||||
|
||||
function closeRestartConfirmDialog() {
|
||||
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
|
||||
// Re-enable confirm button for next open
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
|
||||
// Return focus to the element that opened the dialog
|
||||
if (_restartDialogOpener && _restartDialogOpener.focus) {
|
||||
try { _restartDialogOpener.focus(); } catch (_) {}
|
||||
_restartDialogOpener = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpgradeToServer() {
|
||||
var confirmBtn = $upgradeConfirmBtn;
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||
closeUpgradeModal();
|
||||
|
||||
// Reuse the rebuild modal to show progress
|
||||
// Reuse the rebuild modal to show reboot progress
|
||||
_rebuildFeatureName = "Server + Desktop";
|
||||
_rebuildIsEnabling = true;
|
||||
openRebuildModal();
|
||||
|
||||
try {
|
||||
await apiFetch("/api/role/upgrade-to-server", { method: "POST" });
|
||||
// Server is rebooting — show message and wait for it to come back
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "Rebooting — the setup wizard will guide you through domain and port configuration…";
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||
|
||||
// Poll until server comes back, then redirect to onboarding
|
||||
var pollInterval = setInterval(async function() {
|
||||
try {
|
||||
await apiFetch("/api/ping");
|
||||
clearInterval(pollInterval);
|
||||
window.location.href = "/onboarding";
|
||||
} catch (_) {
|
||||
// Server still down — keep polling
|
||||
}
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
@@ -70,6 +143,47 @@ async function doUpgradeToServer() {
|
||||
|
||||
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
|
||||
|
||||
// ── First-login security banner ───────────────────────────────────
|
||||
|
||||
function showSecurityBanner() {
|
||||
var existing = document.getElementById("security-first-login-banner");
|
||||
if (existing) return;
|
||||
|
||||
var banner = document.createElement("div");
|
||||
banner.id = "security-first-login-banner";
|
||||
banner.className = "security-first-login-banner";
|
||||
banner.innerHTML =
|
||||
'<div class="security-banner-content">' +
|
||||
'<span class="security-banner-icon">\uD83D\uDEE1</span>' +
|
||||
'<span class="security-banner-text">' +
|
||||
'<strong>Did someone else set up this machine?</strong> ' +
|
||||
'If this computer was pre-configured by another person, go to ' +
|
||||
'<strong>Menu \u2192 Security</strong> to reset all passwords and keys. ' +
|
||||
'This ensures only you have access.' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
||||
|
||||
var mainContent = document.querySelector(".main-content");
|
||||
if (mainContent) {
|
||||
mainContent.insertAdjacentElement("beforebegin", banner);
|
||||
} else {
|
||||
document.body.insertAdjacentElement("afterbegin", banner);
|
||||
}
|
||||
|
||||
var dismissBtn = document.getElementById("security-banner-dismiss-btn");
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener("click", async function() {
|
||||
banner.remove();
|
||||
try {
|
||||
await apiFetch("/api/security/banner-dismiss", { method: "POST" });
|
||||
} catch (_) {
|
||||
// Non-fatal
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
@@ -84,6 +198,17 @@ async function init() {
|
||||
// If we can't reach the endpoint, continue to normal dashboard
|
||||
}
|
||||
|
||||
// Show first-login security banner only for machines that went through onboarding
|
||||
// (legacy machines without the onboarding flag will never see this)
|
||||
try {
|
||||
var bannerData = await apiFetch("/api/security/banner-status");
|
||||
if (bannerData && bannerData.show) {
|
||||
showSecurityBanner();
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal — silently ignore
|
||||
}
|
||||
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
_currentRole = cfg.role || "server_plus_desktop";
|
||||
|
||||
@@ -73,28 +73,131 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
var introHtml;
|
||||
if (_currentRole === "node") {
|
||||
introHtml =
|
||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
|
||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
|
||||
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
|
||||
'</ol>';
|
||||
} else {
|
||||
introHtml =
|
||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
|
||||
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
|
||||
}
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
'<p><strong>Before continuing:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
|
||||
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>Njal.la will give you a curl command like:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
||||
'<li>Enter the subdomain and paste that curl command below</li>' +
|
||||
introHtml +
|
||||
'<details style="margin-top:10px;">' +
|
||||
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
||||
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
|
||||
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
||||
'(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>)<br>' +
|
||||
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
|
||||
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
|
||||
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>' +
|
||||
'</ol>' +
|
||||
'</details>' +
|
||||
'<details style="margin-top:6px;">' +
|
||||
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
|
||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||
'<li>In Njal.la, buy the domain you want.</li>' +
|
||||
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
|
||||
'<li>Copy the curl command Njal.la gives you.</li>' +
|
||||
'</ol>' +
|
||||
'</details>' +
|
||||
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) — and paste its curl command.</p>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
npubField +
|
||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||
|
||||
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
|
||||
|
||||
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
|
||||
subdomain = subdomain.trim();
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
if (!subdomain) { alert("Please enter a domain."); return; }
|
||||
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||
|
||||
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/domains/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
domain_name: feat.domain_name,
|
||||
domain: subdomain,
|
||||
ddns_url: ddnsUrl,
|
||||
}),
|
||||
});
|
||||
closeDomainSetupModal();
|
||||
onSaved(npub);
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Enable";
|
||||
alert("Failed to save domain. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
}
|
||||
|
||||
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
if (!$domainSetupModal) return;
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
var currentNpub = "";
|
||||
if (feat.extra_fields && feat.extra_fields.length > 0) {
|
||||
for (var i = 0; i < feat.extra_fields.length; i++) {
|
||||
if (feat.extra_fields[i].id === "nostr_npub") {
|
||||
currentNpub = feat.extra_fields[i].current_value || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
var currentDomain = existingDomain || "";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
|
||||
'<p><strong>Troubleshooting steps:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong>. In Njal.la\'s Name field, note that only the host part is stored (the word before the domain) — not the full domain.</li>' +
|
||||
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' +
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||
'</ol>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
npubField +
|
||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Update</button></div>';
|
||||
|
||||
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
|
||||
|
||||
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||
@@ -124,7 +227,7 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
onSaved(npub);
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Enable";
|
||||
saveBtn.textContent = "Save & Update";
|
||||
alert("Failed to save domain. Please try again.");
|
||||
}
|
||||
});
|
||||
@@ -145,114 +248,59 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
|
||||
: '';
|
||||
|
||||
// Show loading state while fetching port status
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro">Checking port status for <strong>' + escHtml(featureName) + '</strong>…</p>' +
|
||||
'<p class="port-req-hint">Detecting which ports are open on this machine…</p>';
|
||||
function renderPortRequirements(internalIp) {
|
||||
var rows = ports.map(function(p) {
|
||||
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||
}).join("");
|
||||
var ipLine = internalIp
|
||||
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
|
||||
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
|
||||
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||
ipLine +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
|
||||
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
|
||||
"or check your router's port forwarding page.</p>" +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
'</div>';
|
||||
|
||||
document.getElementById("port-req-dismiss-btn").onclick = function() {
|
||||
closePortRequirementsModal();
|
||||
};
|
||||
|
||||
if (onContinue) {
|
||||
document.getElementById("port-req-continue-btn").onclick = function() {
|
||||
closePortRequirementsModal();
|
||||
onContinue();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$portReqModal.classList.add("open");
|
||||
renderPortRequirements(null);
|
||||
|
||||
// Fetch live port status from local system commands (no external calls)
|
||||
fetch("/api/ports/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ports: ports }),
|
||||
})
|
||||
fetch("/api/network")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!$portReqModal.classList.contains("open")) return;
|
||||
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
|
||||
? data.internal_ip : null;
|
||||
var portStatuses = {};
|
||||
(data.ports || []).forEach(function(p) {
|
||||
portStatuses[p.port + "/" + p.protocol] = p.status;
|
||||
});
|
||||
|
||||
var rows = ports.map(function(p) {
|
||||
var key = p.port + "/" + p.protocol;
|
||||
var status = portStatuses[key] || "unknown";
|
||||
var statusHtml;
|
||||
if (status === "listening") {
|
||||
statusHtml = '<span class="port-status-listening" title="Service is running and firewall allows this port">🟢 Listening</span>';
|
||||
} else if (status === "firewall_open") {
|
||||
statusHtml = '<span class="port-status-open" title="Firewall allows this port but no service is bound yet">🟡 Open (idle)</span>';
|
||||
} else if (status === "closed") {
|
||||
statusHtml = '<span class="port-status-closed" title="Firewall blocks this port and/or nothing is listening">🔴 Closed</span>';
|
||||
} else {
|
||||
statusHtml = '<span class="port-status-unknown" title="Status could not be determined">⚪ Unknown</span>';
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td>' +
|
||||
'<td class="port-req-status">' + statusHtml + '</td>' +
|
||||
'</tr>';
|
||||
}).join("");
|
||||
|
||||
var ipLine = internalIp
|
||||
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
|
||||
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
|
||||
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||
ipLine +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th><th>Status</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
|
||||
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
|
||||
"or check your router's port forwarding page.</p>" +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
'</div>';
|
||||
|
||||
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
});
|
||||
|
||||
if (onContinue) {
|
||||
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
onContinue();
|
||||
});
|
||||
}
|
||||
renderPortRequirements(internalIp);
|
||||
})
|
||||
.catch(function() {
|
||||
// Fallback: show static table without status column if fetch fails
|
||||
var rows = ports.map(function(p) {
|
||||
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||
}).join("");
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work with clients outside your local network, ' +
|
||||
'you must configure <strong>port forwarding</strong> in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.</p>' +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
'</div>';
|
||||
|
||||
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
});
|
||||
|
||||
if (onContinue) {
|
||||
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
onContinue();
|
||||
});
|
||||
}
|
||||
.catch(function(err) {
|
||||
console.warn("Failed to fetch network info for port requirements modal:", err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -349,25 +397,52 @@ function handleFeatureToggle(feat, newEnabled) {
|
||||
}
|
||||
|
||||
function proceedAfterConflictCheck() {
|
||||
// Show port requirements notification if the feature has extra port needs
|
||||
var ports = feat.port_requirements || [];
|
||||
if (ports.length > 0) {
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
} else {
|
||||
if (ports.length === 0) {
|
||||
proceedAfterPortCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which ports are actually closed before showing the modal
|
||||
fetch("/api/ports/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ports: ports }),
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error("Port status request failed: " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
var portStatuses = {};
|
||||
(data.ports || []).forEach(function(p) {
|
||||
portStatuses[p.port + "/" + p.protocol] = p.status;
|
||||
});
|
||||
|
||||
var closedPorts = ports.filter(function(p) {
|
||||
var key = p.port + "/" + p.protocol;
|
||||
var status = portStatuses[key] || "unknown";
|
||||
return status !== "listening" && status !== "firewall_open";
|
||||
});
|
||||
|
||||
if (closedPorts.length === 0) {
|
||||
proceedAfterPortCheck();
|
||||
} else {
|
||||
openPortRequirementsModal(feat.name, closedPorts, proceedAfterPortCheck);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn("Failed to fetch port status for feature enable flow:", err);
|
||||
// Safe fallback if status check fails
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
});
|
||||
}
|
||||
|
||||
if (conflictNames.length > 0) {
|
||||
var confirmMsg;
|
||||
if (feat.id === "bip110") {
|
||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?";
|
||||
} else if (feat.id === "bitcoin-core") {
|
||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?";
|
||||
} else {
|
||||
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
|
||||
}
|
||||
if (feat.id === "bitcoin-core") {
|
||||
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||
} else if (conflictNames.length > 0) {
|
||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||
} else {
|
||||
proceedAfterConflictCheck();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ function statusClass(health) {
|
||||
if (health === "disabled") return "disabled";
|
||||
if (health === "syncing") return "syncing";
|
||||
if (STATUS_LOADING_STATES.has(health)) return "loading";
|
||||
if (health === "checking_reachability") return "checking-reachability";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ function statusText(health, enabled) {
|
||||
if (health === "syncing") return "Syncing\u2026";
|
||||
if (!health || health === "unknown") return "Unknown";
|
||||
if (STATUS_LOADING_STATES.has(health)) return health;
|
||||
if (health === "checking_reachability") return "Checking\u2026";
|
||||
return health;
|
||||
}
|
||||
|
||||
@@ -58,3 +60,17 @@ async function apiFetch(path, options) {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
// ── BIP-110 badge state config ────────────────────────────────────
|
||||
// Shared lookup used by tiles.js and service-detail.js.
|
||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
||||
|
||||
var BIP110_BADGE_CONFIG = {
|
||||
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
|
||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
|
||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
|
||||
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
||||
};
|
||||
|
||||
@@ -51,19 +51,26 @@ async function pollRebuildStatus() {
|
||||
if (data.running) return;
|
||||
_rebuildFinished = true;
|
||||
stopRebuildPoll();
|
||||
onRebuildDone(data.result === "success");
|
||||
if (data.result === "reboot_required") {
|
||||
onRebuildDone("reboot_required");
|
||||
} else {
|
||||
onRebuildDone(data.result === "success");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onRebuildDone(success) {
|
||||
function onRebuildDone(result) {
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if (success) {
|
||||
if (result === true) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else if (result === "reboot_required") {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"use strict";
|
||||
|
||||
// ── Security Modal ────────────────────────────────────────────────
|
||||
|
||||
function openSecurityModal() {
|
||||
if ($supportModal) $supportModal.classList.add("open");
|
||||
var title = document.getElementById("support-modal-title");
|
||||
if (title) title.textContent = "\uD83D\uDEE1 Security";
|
||||
|
||||
if ($supportBody) {
|
||||
$supportBody.innerHTML =
|
||||
// ── Section A: Security Reset ──────────────────────────────
|
||||
'<div class="security-section">' +
|
||||
'<h3 class="security-section-title">Security Reset</h3>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'Run this if you are using this physical computer for the first time <strong>AND</strong> ' +
|
||||
'it was not set up by you. This will complete the security setup by resetting all passwords ' +
|
||||
'and your Bitcoin Lightning Node\u2019s private keys.' +
|
||||
'</p>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'You can also run this if you wish to reset all your passwords and your Bitcoin Lightning ' +
|
||||
'Node\u2019s private keys. If you have not transferred the Bitcoin out of this node and did ' +
|
||||
'not back up the private keys, <strong>you will lose your Bitcoin.</strong>' +
|
||||
'</p>' +
|
||||
'<button class="btn btn-primary" id="security-reset-open-btn">Proceed with Security Reset</button>' +
|
||||
'<div id="security-reset-confirm" style="display:none;margin-top:16px;">' +
|
||||
'<div class="security-warning-box">' +
|
||||
'<p class="security-warning-text">' +
|
||||
'<strong>\u26A0\uFE0F This will permanently delete:</strong>' +
|
||||
'</p>' +
|
||||
'<ul class="security-warning-list">' +
|
||||
'<li>All generated passwords and SSH keys</li>' +
|
||||
'<li>LND wallet data (seed words, channels, macaroons)</li>' +
|
||||
'<li>Application databases</li>' +
|
||||
'<li>Vaultwarden data</li>' +
|
||||
'</ul>' +
|
||||
'<p class="security-warning-text">You will go through onboarding again. <strong>This cannot be undone.</strong></p>' +
|
||||
'</div>' +
|
||||
'<div class="security-erase-group">' +
|
||||
'<label class="security-erase-label" for="security-erase-input">Type <strong>ERASE</strong> to confirm:</label>' +
|
||||
'<input class="security-erase-input" type="text" id="security-erase-input" autocomplete="off" placeholder="ERASE" />' +
|
||||
'</div>' +
|
||||
'<div class="security-reset-actions">' +
|
||||
'<button class="btn btn-close-modal" id="security-reset-cancel-btn">Cancel</button>' +
|
||||
'<button class="btn btn-danger" id="security-reset-confirm-btn" disabled>Erase & Reset</button>' +
|
||||
'</div>' +
|
||||
'<div id="security-reset-status" class="security-status-msg"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<hr class="security-divider" />' +
|
||||
|
||||
// ── Section B: Verify System Integrity ────────────────────
|
||||
'<div class="security-section">' +
|
||||
'<h3 class="security-section-title">Verify System Integrity</h3>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'Your Sovran_SystemsOS is built with NixOS \u2014 a system designed for complete transparency ' +
|
||||
'and reproducibility. Every piece of software on this machine is built from publicly auditable ' +
|
||||
'source code and verified using cryptographic hashes.' +
|
||||
'</p>' +
|
||||
'<p class="security-section-desc">This verification confirms three things:</p>' +
|
||||
'<ol class="security-verify-list">' +
|
||||
'<li>' +
|
||||
'<strong>Source Code Match</strong> \u2014 The system configuration on this machine matches ' +
|
||||
'the exact commit published in the public repository. No hidden changes were added.' +
|
||||
'</li>' +
|
||||
'<li>' +
|
||||
'<strong>Binary Integrity</strong> \u2014 Every installed package in the system store is ' +
|
||||
'verified against its expected cryptographic hash. If any binary, library, or config file ' +
|
||||
'was tampered with, it will be detected.' +
|
||||
'</li>' +
|
||||
'<li>' +
|
||||
'<strong>Running System Match</strong> \u2014 The currently running system matches what the ' +
|
||||
'configuration says it should be. No unauthorized modifications are active.' +
|
||||
'</li>' +
|
||||
'</ol>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'In short: if this verification passes, you can be confident that the software running on ' +
|
||||
'your machine is exactly what is published \u2014 nothing more, nothing less.' +
|
||||
'</p>' +
|
||||
'<button class="btn btn-primary" id="security-verify-btn">Verify Now</button>' +
|
||||
'<div id="security-verify-results" style="display:none;margin-top:16px;"></div>' +
|
||||
'</div>';
|
||||
|
||||
// ── Wire Security Reset flow
|
||||
var resetOpenBtn = document.getElementById("security-reset-open-btn");
|
||||
var resetConfirmDiv = document.getElementById("security-reset-confirm");
|
||||
var eraseInput = document.getElementById("security-erase-input");
|
||||
var resetConfirmBtn = document.getElementById("security-reset-confirm-btn");
|
||||
var resetCancelBtn = document.getElementById("security-reset-cancel-btn");
|
||||
var resetStatus = document.getElementById("security-reset-status");
|
||||
|
||||
if (resetOpenBtn) {
|
||||
resetOpenBtn.addEventListener("click", function() {
|
||||
resetOpenBtn.style.display = "none";
|
||||
if (resetConfirmDiv) resetConfirmDiv.style.display = "";
|
||||
if (eraseInput) eraseInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (eraseInput && resetConfirmBtn) {
|
||||
eraseInput.addEventListener("input", function() {
|
||||
resetConfirmBtn.disabled = eraseInput.value.trim() !== "ERASE";
|
||||
});
|
||||
}
|
||||
|
||||
if (resetCancelBtn) {
|
||||
resetCancelBtn.addEventListener("click", function() {
|
||||
if (resetConfirmDiv) resetConfirmDiv.style.display = "none";
|
||||
if (resetOpenBtn) resetOpenBtn.style.display = "";
|
||||
if (eraseInput) eraseInput.value = "";
|
||||
if (resetConfirmBtn) resetConfirmBtn.disabled = true;
|
||||
if (resetStatus) { resetStatus.textContent = ""; resetStatus.className = "security-status-msg"; }
|
||||
});
|
||||
}
|
||||
|
||||
if (resetConfirmBtn) {
|
||||
resetConfirmBtn.addEventListener("click", async function() {
|
||||
if (!eraseInput || eraseInput.value.trim() !== "ERASE") return;
|
||||
resetConfirmBtn.disabled = true;
|
||||
resetConfirmBtn.textContent = "Erasing\u2026";
|
||||
|
||||
// Show the full-screen blocking overlay immediately so the user knows
|
||||
// the wipe is in progress even while the API call runs synchronously.
|
||||
var $secResetOverlay = document.getElementById("security-reset-overlay");
|
||||
var $secResetStep = document.getElementById("security-reset-overlay-step");
|
||||
if ($secResetOverlay) $secResetOverlay.classList.add("visible");
|
||||
// Close the support modal so its content doesn't bleed through the overlay
|
||||
if ($supportModal) $supportModal.classList.remove("open");
|
||||
|
||||
if (resetStatus) { resetStatus.textContent = "Running security reset\u2026"; resetStatus.className = "security-status-msg security-status-info"; }
|
||||
try {
|
||||
var data = await apiFetch("/api/security/reset", { method: "POST" });
|
||||
|
||||
// Switch to Phase 2: show the new password and wait for user confirmation
|
||||
var phase1 = document.getElementById("security-reset-phase1");
|
||||
var phase2 = document.getElementById("security-reset-phase2");
|
||||
var passwordBox = document.getElementById("security-reset-new-password");
|
||||
var rebootBtn = document.getElementById("security-reset-reboot-btn");
|
||||
|
||||
if (phase1) phase1.style.display = "none";
|
||||
if (phase2) phase2.style.display = "";
|
||||
if (passwordBox && data.new_password) passwordBox.textContent = data.new_password;
|
||||
|
||||
if (rebootBtn) {
|
||||
// Keep button disabled for 5 seconds to prevent accidental clicks
|
||||
var countdown = 5;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
var timer = setInterval(function() {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
|
||||
} else {
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
rebootBtn.addEventListener("click", function() {
|
||||
rebootBtn.disabled = true;
|
||||
rebootBtn.textContent = "Restarting\u2026";
|
||||
// Hide the security reset overlay so the shared reboot overlay is visible
|
||||
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
|
||||
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
|
||||
doReboot();
|
||||
}, { once: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if ($secResetOverlay) $secResetOverlay.classList.remove("visible");
|
||||
if (resetStatus) { resetStatus.textContent = "\u2717 Error: " + (err.message || "Reset failed."); resetStatus.className = "security-status-msg security-status-error"; }
|
||||
resetConfirmBtn.disabled = false;
|
||||
resetConfirmBtn.textContent = "Erase & Reset";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Wire Verify System Integrity
|
||||
var verifyBtn = document.getElementById("security-verify-btn");
|
||||
var verifyResults = document.getElementById("security-verify-results");
|
||||
|
||||
if (verifyBtn && verifyResults) {
|
||||
verifyBtn.addEventListener("click", async function() {
|
||||
verifyBtn.disabled = true;
|
||||
verifyBtn.textContent = "Verifying\u2026";
|
||||
verifyResults.style.display = "";
|
||||
verifyResults.innerHTML = '<p class="security-verify-loading">\u231B Running verification checks\u2026 This may take a few minutes.</p>';
|
||||
|
||||
try {
|
||||
var data = await apiFetch("/api/security/verify-integrity", { method: "POST" });
|
||||
var html = '<div class="security-verify-result-card">';
|
||||
|
||||
// Flake commit
|
||||
html += '<div class="security-verify-row">';
|
||||
html += '<span class="security-verify-label">Source Commit:</span>';
|
||||
html += '<span class="security-verify-value security-verify-mono">' + escHtml(data.flake_commit || "unknown") + '</span>';
|
||||
if (data.repo_url) {
|
||||
html += '<a class="security-verify-link" href="' + escHtml(data.repo_url) + '" target="_blank" rel="noopener noreferrer">View on Gitea \u2197</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Store verification
|
||||
var storeOk = data.store_verified === true;
|
||||
html += '<div class="security-verify-row">';
|
||||
html += '<span class="security-verify-label">Binary Integrity:</span>';
|
||||
html += '<span class="security-verify-badge ' + (storeOk ? "security-verify-pass" : "security-verify-fail") + '">';
|
||||
html += storeOk ? "\u2705 PASS" : "\u274C FAIL";
|
||||
html += '</span>';
|
||||
html += '</div>';
|
||||
if (!storeOk && data.store_errors && data.store_errors.length > 0) {
|
||||
html += '<details class="security-verify-errors"><summary>Show errors (' + data.store_errors.length + ')</summary>';
|
||||
html += '<pre class="security-verify-pre">' + escHtml(data.store_errors.join("\n")) + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
|
||||
// System match
|
||||
var sysOk = data.system_matches === true;
|
||||
html += '<div class="security-verify-row">';
|
||||
html += '<span class="security-verify-label">Running System Match:</span>';
|
||||
html += '<span class="security-verify-badge ' + (sysOk ? "security-verify-pass" : "security-verify-fail") + '">';
|
||||
html += sysOk ? "\u2705 PASS" : "\u274C FAIL";
|
||||
html += '</span>';
|
||||
html += '</div>';
|
||||
if (!sysOk) {
|
||||
html += '<div class="security-verify-path-row">';
|
||||
html += '<span class="security-verify-path-label">Current:</span><code class="security-verify-mono">' + escHtml(data.current_system_path || "") + '</code>';
|
||||
html += '</div>';
|
||||
html += '<div class="security-verify-path-row">';
|
||||
html += '<span class="security-verify-path-label">Expected:</span><code class="security-verify-mono">' + escHtml(data.expected_system_path || "") + '</code>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
verifyResults.innerHTML = html;
|
||||
} catch (err) {
|
||||
verifyResults.innerHTML = '<p class="security-status-msg security-status-error">\u274C Verification failed: ' + escHtml(err.message || "Unknown error") + '</p>';
|
||||
}
|
||||
|
||||
verifyBtn.disabled = false;
|
||||
verifyBtn.textContent = "Verify Now";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,16 @@ function _renderCredsHtml(credentials, unit) {
|
||||
for (var i = 0; i < credentials.length; i++) {
|
||||
var cred = credentials[i];
|
||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||
var displayValue = linkify(cred.value);
|
||||
var qrBlock = "";
|
||||
if (cred.qrcode) {
|
||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
||||
}
|
||||
// If qronly, render the label + QR block only — skip value and copy button
|
||||
if (cred.qronly) {
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
|
||||
continue;
|
||||
}
|
||||
var displayValue = linkify(cred.value);
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
|
||||
}
|
||||
return html;
|
||||
@@ -55,7 +60,20 @@ function _attachCopyHandlers(container) {
|
||||
|
||||
async function openServiceDetailModal(unit, name, icon) {
|
||||
if (!$credsModal) return;
|
||||
if ($credsTitle) $credsTitle.textContent = name;
|
||||
if ($credsTitle) {
|
||||
$credsTitle.innerHTML = '';
|
||||
if (icon) {
|
||||
var iconImg = document.createElement("img");
|
||||
iconImg.className = "creds-title-icon";
|
||||
iconImg.src = "/static/icons/" + escHtml(icon) + ".svg";
|
||||
iconImg.alt = name;
|
||||
iconImg.onerror = function() { this.style.display = "none"; };
|
||||
$credsTitle.appendChild(iconImg);
|
||||
}
|
||||
var nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = name;
|
||||
$credsTitle.appendChild(nameSpan);
|
||||
}
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||
$credsModal.classList.add("open");
|
||||
|
||||
@@ -89,156 +107,137 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Section C: Ports (only if service has port_requirements)
|
||||
if (data.port_statuses && data.port_statuses.length > 0) {
|
||||
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
|
||||
// Section B2: BIP-110 live status (bip110 tile only)
|
||||
if (icon === 'bip110' && data.bip110) {
|
||||
var bip110 = data.bip110;
|
||||
var bip110State = bip110.state || 'unknown';
|
||||
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
||||
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
||||
'<div class="bip110-status-row">' +
|
||||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
||||
bip110Source +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section C: Domain diagnostics (domain services)
|
||||
if (data.needs_domain) {
|
||||
var steps = data.domain_check_steps || [];
|
||||
var stepsHtml = "";
|
||||
steps.forEach(function(step) {
|
||||
var iconLabel = "—";
|
||||
if (step.status === "ok") iconLabel = "✅";
|
||||
else if (step.status === "error") iconLabel = "❌";
|
||||
else if (step.status === "warning") iconLabel = "⚠️";
|
||||
else if (step.status === "skipped") iconLabel = "⏭️";
|
||||
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
|
||||
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||||
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
|
||||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
var domainActionHtml = "";
|
||||
var ds = data.domain_status || {};
|
||||
if (!data.domain && data.domain_name) {
|
||||
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>';
|
||||
} else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) {
|
||||
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
||||
stepsHtml +
|
||||
domainActionHtml +
|
||||
'</div>';
|
||||
|
||||
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
|
||||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||
var internalIp = trimmedInternalIp || "";
|
||||
var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect";
|
||||
var routerIpHelp = internalIp
|
||||
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
|
||||
: "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule.";
|
||||
var routerNextStep = internalIp
|
||||
? 'Next step: Log in to your router and create forwarding rules for the ports above. Set the destination/internal IP to <strong>' + internalIpHtml + '</strong>.'
|
||||
: 'Next step: Log in to your router and create forwarding rules for the ports above. Use this computer’s internal IP as the destination/internal IP.';
|
||||
var domainConfigured = !!(data.domain && String(data.domain).trim());
|
||||
var extraRows = "";
|
||||
data.extra_ports.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (!effectiveEnabled) {
|
||||
statusIcon = "⚠ Configure Element Call first";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (!domainConfigured) {
|
||||
statusIcon = "⚠ Configure domain first";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "listening") {
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "❌ Not ready yet";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Could not check";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
extraRows += '<tr>' +
|
||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
|
||||
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||
'<div class="svc-detail-port-note">Forward these ports in your router to this Sovran_SystemsOS computer.</div>' +
|
||||
'<div class="svc-detail-port-note"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
|
||||
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
||||
'<tbody>' + extraRows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<div class="svc-detail-port-note">The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.</div>' +
|
||||
'<div class="svc-detail-port-note">' + routerNextStep + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||
// Non-domain services (SSH) keep local single-port checks.
|
||||
var portTableRows = "";
|
||||
data.port_statuses.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (p.status === "listening") {
|
||||
statusIcon = "✅ Open";
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "🟡 Firewall open";
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "🔴 Closed";
|
||||
statusIcon = "❌ Not ready";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Unknown";
|
||||
statusIcon = "— Could not check";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
var desc = p.description;
|
||||
var portNum = parseInt(p.port, 10);
|
||||
if (portNum === 80 || portNum === 443) {
|
||||
desc += " (shared — all services)";
|
||||
}
|
||||
portTableRows += '<tr>' +
|
||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="svc-detail-port-table-desc">' + escHtml(desc) + '</td>' +
|
||||
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
|
||||
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
|
||||
var troubleshootHtml = "";
|
||||
if (anyPortClosed) {
|
||||
var sharedPorts = [];
|
||||
var specificPorts = [];
|
||||
data.port_statuses.forEach(function(p) {
|
||||
if (p.status === "closed") {
|
||||
var portNum = parseInt(p.port, 10);
|
||||
if (portNum === 80 || portNum === 443) {
|
||||
sharedPorts.push(p);
|
||||
} else {
|
||||
specificPorts.push(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var troubleParts = [];
|
||||
|
||||
if (sharedPorts.length > 0) {
|
||||
troubleParts.push(
|
||||
'<strong>⚠️ Ports 80 and 443 need to be forwarded on your router.</strong>' +
|
||||
'<p style="margin-top:8px">These are <strong>shared system ports</strong> — you only need to set them up once and they cover all your domain-based services ' +
|
||||
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).</p>' +
|
||||
'<p style="margin-top:8px">If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your router\'s admin panel (usually <code>http://192.168.1.1</code>)</li>' +
|
||||
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
|
||||
'<li>Forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong> to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
||||
'<li>Save your router settings</li>' +
|
||||
'</ol>' +
|
||||
'<p style="margin-top:8px">💡 Once these two ports are forwarded, you won\'t see this warning on any service again.</p>'
|
||||
);
|
||||
}
|
||||
|
||||
if (specificPorts.length > 0) {
|
||||
var portList = specificPorts.map(function(p) {
|
||||
return '<strong>' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ')</strong> — ' + escHtml(p.description);
|
||||
}).join('<br>');
|
||||
|
||||
troubleParts.push(
|
||||
'<strong>⚠️ This service requires additional ports to be forwarded:</strong>' +
|
||||
'<p style="margin-top:8px">' + portList + '</p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your router\'s admin panel</li>' +
|
||||
'<li>Forward each port listed above to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
||||
'<li>Save your router settings</li>' +
|
||||
'</ol>'
|
||||
);
|
||||
}
|
||||
|
||||
troubleshootHtml = '<div class="svc-detail-troubleshoot">' + troubleParts.join('<hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);margin:16px 0">') + '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Port Status</div>' +
|
||||
'<div class="svc-detail-section-title">Port Requirements</div>' +
|
||||
'<div class="svc-detail-port-note">This shows whether Sovran_SystemsOS is ready to use this port on this computer. If you need access from outside your home network, forward this port in your router.</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
|
||||
'</tr></thead>' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
||||
'<tbody>' + portTableRows + '</tbody>' +
|
||||
'</table>' +
|
||||
troubleshootHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section D: Domain (only if service needs_domain)
|
||||
if (data.needs_domain) {
|
||||
var domainStatusHtml = "";
|
||||
var ds = data.domain_status || {};
|
||||
var domainBadge = "";
|
||||
|
||||
if (data.domain) {
|
||||
if (ds.status === "connected") {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
|
||||
} else if (ds.status === "dns_mismatch") {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
|
||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
|
||||
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
||||
'<li>Find your domain and check the Dynamic DNS record</li>' +
|
||||
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
||||
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
|
||||
'</ol>' +
|
||||
'</div>';
|
||||
} else if (ds.status === "unresolvable") {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
|
||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
|
||||
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
||||
'<li>Find the domain you purchased for this service</li>' +
|
||||
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
||||
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
|
||||
'<li>You can re-enter it in the Feature Manager to update your configuration</li>' +
|
||||
'</ol>' +
|
||||
'</div>';
|
||||
} else {
|
||||
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
|
||||
}
|
||||
} else {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
|
||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
|
||||
'<p style="margin-top:8px">To get this service working:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
|
||||
'<li>Go to the <strong>Feature Manager</strong> in the sidebar</li>' +
|
||||
'<li>Find this service and configure your domain through the setup wizard</li>' +
|
||||
'</ol>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Domain</div>' +
|
||||
domainBadge +
|
||||
domainStatusHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -252,6 +251,10 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||
'</div>' : "") +
|
||||
(unit === "root-password-setup.service" ?
|
||||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||
'<button class="matrix-action-btn" id="sys-change-pw-btn">🔑 Change Free Account Password</button>' +
|
||||
'</div>' : "") +
|
||||
'</div>';
|
||||
} else if (!data.enabled && !data.feature) {
|
||||
html += '<div class="svc-detail-section">' +
|
||||
@@ -276,7 +279,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||
|
||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
|
||||
var addonSectionTitle = (feat.id === "bitcoin-core")
|
||||
? "\u20BF Bitcoin Node Selection"
|
||||
: "\uD83D\uDD27 Addon Feature";
|
||||
|
||||
@@ -309,6 +312,15 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||
html += '<div class="svc-detail-section svc-detail-restart-section">' +
|
||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
|
||||
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
|
||||
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
$credsBody.innerHTML = html;
|
||||
_attachCopyHandlers($credsBody);
|
||||
|
||||
@@ -319,6 +331,11 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); });
|
||||
}
|
||||
|
||||
if (unit === "root-password-setup.service") {
|
||||
var sysPwBtn = document.getElementById("sys-change-pw-btn");
|
||||
if (sysPwBtn) sysPwBtn.addEventListener("click", function() { openSystemChangePasswordModal(unit, name, icon); });
|
||||
}
|
||||
|
||||
if (data.feature) {
|
||||
var addonBtn = document.getElementById("svc-detail-addon-btn");
|
||||
if (addonBtn) {
|
||||
@@ -329,6 +346,59 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var restartBtn = document.getElementById("svc-detail-restart-btn");
|
||||
var restartResult = document.getElementById("svc-detail-restart-result");
|
||||
if (restartBtn && restartResult) {
|
||||
var RESTART_REFRESH_DELAY_MS = 3000;
|
||||
restartBtn.addEventListener("click", async function() {
|
||||
restartBtn.disabled = true;
|
||||
restartBtn.textContent = "Restarting…";
|
||||
restartResult.className = "svc-detail-restart-result";
|
||||
restartResult.textContent = "";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/service/" + encodeURIComponent(unit) + "/restart", { method: "POST" });
|
||||
restartResult.classList.add("success");
|
||||
restartResult.textContent = "✅ Service restarted successfully.";
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = "🔄 Restart Service";
|
||||
setTimeout(function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
}, RESTART_REFRESH_DELAY_MS);
|
||||
} catch (e) {
|
||||
restartResult.classList.add("error");
|
||||
restartResult.textContent = e && e.message ? e.message : "Failed to restart service. Please check service logs and try again.";
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = "🔄 Restart Service";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Configure / Reconfigure Domain buttons (for non-feature services that need a domain)
|
||||
var configDomainBtn = document.getElementById("svc-detail-config-domain-btn");
|
||||
var reconfigDomainBtn = document.getElementById("svc-detail-reconfig-domain-btn");
|
||||
if ((configDomainBtn || reconfigDomainBtn) && data.needs_domain && data.domain_name) {
|
||||
var pseudoFeat = {
|
||||
id: data.domain_name,
|
||||
name: name,
|
||||
domain_name: data.domain_name,
|
||||
needs_ddns: true,
|
||||
extra_fields: []
|
||||
};
|
||||
if (configDomainBtn) configDomainBtn.addEventListener("click", function() {
|
||||
closeCredsModal();
|
||||
openDomainSetupModal(pseudoFeat, function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
});
|
||||
if (reconfigDomainBtn) reconfigDomainBtn.addEventListener("click", function() {
|
||||
closeCredsModal();
|
||||
openDomainReconfigureModal(pseudoFeat, data.domain || "", function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
|
||||
}
|
||||
@@ -336,9 +406,22 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
|
||||
// ── Credentials info modal ────────────────────────────────────────
|
||||
|
||||
async function openCredsModal(unit, name) {
|
||||
async function openCredsModal(unit, name, icon) {
|
||||
if (!$credsModal) return;
|
||||
if ($credsTitle) $credsTitle.textContent = name + " — Connection Info";
|
||||
if ($credsTitle) {
|
||||
$credsTitle.innerHTML = '';
|
||||
if (icon) {
|
||||
var iconImg = document.createElement("img");
|
||||
iconImg.className = "creds-title-icon";
|
||||
iconImg.src = "/static/icons/" + escHtml(icon) + ".svg";
|
||||
iconImg.alt = name;
|
||||
iconImg.onerror = function() { this.style.display = "none"; };
|
||||
$credsTitle.appendChild(iconImg);
|
||||
}
|
||||
var nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = name + " — Connection Info";
|
||||
$credsTitle.appendChild(nameSpan);
|
||||
}
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||
$credsModal.classList.add("open");
|
||||
try {
|
||||
@@ -475,4 +558,95 @@ function openMatrixChangePasswordModal(unit, name, icon) {
|
||||
});
|
||||
}
|
||||
|
||||
function openSystemChangePasswordModal(unit, name, icon) {
|
||||
if (!$credsBody) return;
|
||||
$credsBody.innerHTML =
|
||||
'<div class="sys-chpw-header">' +
|
||||
'<div class="sys-chpw-title">🔑 Change Free Account & Hub Login Password</div>' +
|
||||
'<div class="sys-chpw-desc">This updates the password for the <strong>free</strong> user account. <strong>This is also your Sovran Hub login password</strong> — both will change.</div>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
|
||||
'<div class="pw-input-wrap">' +
|
||||
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
|
||||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-new-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||||
'</div>' +
|
||||
'<div class="pw-hint">Password must be at least 8 characters.</div></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-confirm">Confirm Password</label>' +
|
||||
'<div class="pw-input-wrap">' +
|
||||
'<input class="matrix-form-input" type="password" id="sys-chpw-confirm" placeholder="Confirm new password" autocomplete="new-password">' +
|
||||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-confirm-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||||
'</div></div>' +
|
||||
'<div class="pw-credentials-note">⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile.</div>' +
|
||||
'<div class="matrix-form-actions">' +
|
||||
'<button class="matrix-form-back" id="sys-chpw-back-btn">← Back</button>' +
|
||||
'<button class="matrix-form-submit" id="sys-chpw-submit-btn">Change Password</button>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-result" id="sys-chpw-result"></div>';
|
||||
|
||||
document.getElementById("sys-chpw-back-btn").addEventListener("click", function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() {
|
||||
var inp = document.getElementById("sys-chpw-new");
|
||||
var isHidden = inp.type === "password";
|
||||
inp.type = isHidden ? "text" : "password";
|
||||
this.textContent = isHidden ? "👁🗨" : "👁";
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-confirm-toggle").addEventListener("click", function() {
|
||||
var inp = document.getElementById("sys-chpw-confirm");
|
||||
var isHidden = inp.type === "password";
|
||||
inp.type = isHidden ? "text" : "password";
|
||||
this.textContent = isHidden ? "👁🗨" : "👁";
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() {
|
||||
var submitBtn = document.getElementById("sys-chpw-submit-btn");
|
||||
var resultEl = document.getElementById("sys-chpw-result");
|
||||
var newPassword = document.getElementById("sys-chpw-new").value || "";
|
||||
var confirmPassword = document.getElementById("sys-chpw-confirm").value || "";
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Both password fields are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Password must be at least 8 characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Changing…";
|
||||
resultEl.className = "matrix-form-result";
|
||||
resultEl.textContent = "";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
|
||||
});
|
||||
resultEl.className = "matrix-form-result success";
|
||||
resultEl.textContent = "✅ Free account & Hub login password changed successfully.";
|
||||
submitBtn.textContent = "Change Password";
|
||||
submitBtn.disabled = false;
|
||||
} catch (err) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
|
||||
submitBtn.textContent = "Change Password";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
|
||||
|
||||
@@ -49,6 +49,9 @@ const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||
const $rebootErrorCard = document.getElementById("reboot-error-card");
|
||||
const $rebootSubmessage = document.getElementById("reboot-submessage");
|
||||
|
||||
const $credsModal = document.getElementById("creds-modal");
|
||||
const $credsTitle = document.getElementById("creds-modal-title");
|
||||
@@ -59,6 +62,8 @@ const $supportModal = document.getElementById("support-modal");
|
||||
const $supportBody = document.getElementById("support-body");
|
||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||
|
||||
const $logoutBtn = document.getElementById("btn-logout");
|
||||
|
||||
// Feature Manager — rebuild modal
|
||||
const $rebuildModal = document.getElementById("rebuild-modal");
|
||||
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
||||
@@ -99,5 +104,14 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||
|
||||
// Restart confirm dialog
|
||||
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
|
||||
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
|
||||
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
|
||||
const $restartConflictBox = document.getElementById("restart-conflict-box");
|
||||
|
||||
// Header reboot button
|
||||
const $headerRebootBtn = document.getElementById("btn-header-reboot");
|
||||
|
||||
// System status banner
|
||||
// (removed — health is now shown per-tile via the composite health field)
|
||||
@@ -500,9 +500,8 @@ function renderBackupReady(drives) {
|
||||
'<div class="support-steps-title">What gets backed up</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>Bitcoin & Lightning wallet data (<code>/var/lib/lnd</code>)</li>',
|
||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||
'<li>Domain configurations (<code>/var/lib/domains</code>)</li>',
|
||||
'<li>System service data (<code>/var/lib</code>) including Vaultwarden, bitcoind, LND, sovran-hub, domains, and secrets</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
var _btcSyncPrev = {};
|
||||
|
||||
// ── BIP-110 badge helper ──────────────────────────────────────────
|
||||
|
||||
function _renderBip110Badge(bip110) {
|
||||
if (!bip110) return '';
|
||||
var state = bip110.state || 'unknown';
|
||||
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
||||
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
||||
}
|
||||
|
||||
function _firstElementFromHtml(html) {
|
||||
var tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.firstElementChild || null;
|
||||
}
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
@@ -89,10 +104,22 @@ function renderSidebarSupport(supportServices) {
|
||||
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
||||
$sidebarSupport.appendChild(backupBtn);
|
||||
|
||||
// ── Security button
|
||||
var securityBtn = document.createElement("button");
|
||||
securityBtn.className = "sidebar-support-btn";
|
||||
securityBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Security</span>' +
|
||||
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||
'</span>';
|
||||
securityBtn.addEventListener("click", function() { openSecurityModal(); });
|
||||
$sidebarSupport.appendChild(securityBtn);
|
||||
|
||||
// ── Upgrade button (Node role only)
|
||||
if (_currentRole === "node") {
|
||||
var upgradeBtn = document.createElement("button");
|
||||
upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn";
|
||||
upgradeBtn.className = "sidebar-support-btn";
|
||||
upgradeBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">🚀</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
@@ -118,8 +145,6 @@ function buildTile(svc) {
|
||||
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
||||
tile.dataset.unit = svc.unit;
|
||||
tile.dataset.tileId = tileId(svc);
|
||||
if (dis) tile.title = svc.name + " is not enabled in custom.nix";
|
||||
|
||||
if (isSupport) {
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click for help</span></div>';
|
||||
tile.style.cursor = "pointer";
|
||||
@@ -127,7 +152,7 @@ function buildTile(svc) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
if (svc.sync_ibd) {
|
||||
if (svc.sync_ibd && svc.enabled) {
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var id = tileId(svc);
|
||||
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
||||
@@ -155,7 +180,8 @@ function buildTile(svc) {
|
||||
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -193,7 +219,7 @@ function updateTiles(services) {
|
||||
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
||||
if (!tile) continue;
|
||||
|
||||
if (svc.sync_ibd) {
|
||||
if (svc.sync_ibd && svc.enabled) {
|
||||
// If tile was previously normal, rebuild it with the sync layout
|
||||
if (!tile.querySelector(".tile-sync-container")) {
|
||||
var newTile = buildTile(svc);
|
||||
@@ -255,6 +281,23 @@ function updateTiles(services) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update BIP-110 badge for bip110 tiles
|
||||
if (svc.icon === 'bip110') {
|
||||
var badgeHtml = _renderBip110Badge(svc.bip110);
|
||||
var badgeEl = tile.querySelector(".tile-bip110-badge");
|
||||
if (badgeEl) {
|
||||
// Replace existing badge in-place
|
||||
var newBadge = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
|
||||
} else if (badgeHtml) {
|
||||
// Insert badge after version label (or after tile-name if no version)
|
||||
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
|
||||
if (anchorEl) {
|
||||
var newBadgeEl = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,31 @@
|
||||
// ── Update modal ──────────────────────────────────────────────────
|
||||
|
||||
function openUpdateModal() {
|
||||
if (!$modal) return;
|
||||
apiFetch("/api/updates/check")
|
||||
.then(function(data) {
|
||||
if (!data.available) {
|
||||
stopUpdatePoll();
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
_updateFinished = true;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
$modal.classList.add("open");
|
||||
return;
|
||||
}
|
||||
_doOpenUpdateModal();
|
||||
})
|
||||
.catch(function() {
|
||||
_doOpenUpdateModal();
|
||||
});
|
||||
}
|
||||
|
||||
function _doOpenUpdateModal() {
|
||||
if (!$modal) return;
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
@@ -37,6 +62,15 @@ function startUpdate() {
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.status === "no_updates") {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
_updateFinished = true;
|
||||
return;
|
||||
}
|
||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
startUpdatePoll();
|
||||
@@ -60,25 +94,68 @@ async function pollUpdateStatus() {
|
||||
if (_updateFinished) return;
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||
if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; }
|
||||
if (_serverWasDown) {
|
||||
_serverWasDown = false;
|
||||
if (!data.running) {
|
||||
// The update finished while the server was restarting. Reset to
|
||||
// offset 0 and re-fetch so the complete log is shown from the top.
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
try {
|
||||
var fullData = await apiFetch("/api/updates/status?offset=0");
|
||||
if (fullData.log) appendLog(fullData.log);
|
||||
_updateLogOffset = fullData.offset;
|
||||
} catch (e) {
|
||||
// If the re-fetch fails, fall through with whatever we have.
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
}
|
||||
if (data.result === "reboot_required") {
|
||||
appendLog("[Server restarted — update completed, reboot required.]\n");
|
||||
} else if (data.result === "success") {
|
||||
appendLog("[Server restarted — update completed successfully.]\n");
|
||||
} else {
|
||||
appendLog("[Server restarted — update encountered an error.]\n");
|
||||
}
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
if (data.result === "reboot_required") {
|
||||
onUpdateDone("reboot_required");
|
||||
} else {
|
||||
onUpdateDone(data.result === "success");
|
||||
}
|
||||
return;
|
||||
}
|
||||
appendLog("[Server reconnected]\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
}
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
if (data.running) return;
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
if (data.result === "success") onUpdateDone(true);
|
||||
else onUpdateDone(false);
|
||||
if (data.result === "reboot_required") {
|
||||
onUpdateDone("reboot_required");
|
||||
} else if (data.result === "success") {
|
||||
onUpdateDone(true);
|
||||
} else {
|
||||
onUpdateDone(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateDone(success) {
|
||||
function onUpdateDone(result) {
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
if (success) {
|
||||
if (result === true) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else if (result === "reboot_required") {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
@@ -100,21 +177,78 @@ function saveErrorReport() {
|
||||
|
||||
// ── Reboot ────────────────────────────────────────────────────────
|
||||
|
||||
var _rebootStartTime = 0;
|
||||
var _serverWentDown = false;
|
||||
var _rebootFailed = false;
|
||||
|
||||
function _setRebootStatus(msg) {
|
||||
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
||||
}
|
||||
|
||||
function doReboot() {
|
||||
if ($modal) $modal.classList.remove("open");
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
stopRebuildPoll();
|
||||
// Reset overlay to main card
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
|
||||
_setRebootStatus("Sending restart request\u2026");
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
fetch("/api/reboot", { method: "POST" }).catch(function() {});
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
_rebootFailed = false;
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
// Definitive HTTP error — server rejected the request before going down
|
||||
_rebootFailed = true;
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "none";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
|
||||
// Leave overlay visible so the error card is shown
|
||||
}
|
||||
// HTTP 2xx: request accepted, proceed with polling
|
||||
})
|
||||
.catch(function() {
|
||||
// Connection dropped or request aborted — the server is likely already going
|
||||
// down as part of the restart. Treat as success and continue polling.
|
||||
});
|
||||
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
}
|
||||
|
||||
function waitForServerReboot() {
|
||||
fetch("/api/config", { cache: "no-store" })
|
||||
if (_rebootFailed) return;
|
||||
// Update status on first check (server hasn't gone down yet)
|
||||
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
||||
|
||||
fetch("/api/ping", { cache: "no-store", signal: controller.signal, headers: { "Connection": "close" } })
|
||||
.then(function(res) {
|
||||
if (res.ok) window.location.reload();
|
||||
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
clearTimeout(timeoutId);
|
||||
if (_serverWentDown) {
|
||||
// Server is responding after having been down — reboot is complete.
|
||||
// Any response (even 401/500) means the server process is back.
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
||||
// Server still responding but hasn't gone down yet — keep waiting
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
} else {
|
||||
// Been over 90 seconds and server is responding — just reload
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
|
||||
.catch(function() {
|
||||
clearTimeout(timeoutId);
|
||||
if (!_serverWentDown) {
|
||||
_serverWentDown = true;
|
||||
_setRebootStatus("The computer is restarting\u2026");
|
||||
}
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||
Drives the 4-step post-install setup flow. */
|
||||
Drives the 5-step post-install setup flow. */
|
||||
"use strict";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
// Steps to skip per role (steps 2 and 3 involve domain/port setup)
|
||||
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
|
||||
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
|
||||
const ROLE_SKIP_STEPS = {
|
||||
"desktop": [2, 3],
|
||||
"node": [2, 3],
|
||||
"desktop": [3, 4],
|
||||
"node": [3, 4],
|
||||
};
|
||||
|
||||
// ── Role state (loaded at init) ───────────────────────────────────
|
||||
@@ -32,6 +33,7 @@ const DOMAIN_DEFS = [
|
||||
var _currentStep = 1;
|
||||
var _servicesData = null;
|
||||
var _domainsData = null;
|
||||
var _migrationOccurred = false;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +66,48 @@ function setStatus(elId, msg, type) {
|
||||
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
|
||||
}
|
||||
|
||||
function updateStep5Checklist() {
|
||||
var checklist = document.getElementById("onboarding-checklist");
|
||||
if (!checklist) return;
|
||||
var existing = document.getElementById("onboarding-migration-check");
|
||||
if (_migrationOccurred) {
|
||||
if (!existing) {
|
||||
var li = document.createElement("li");
|
||||
li.id = "onboarding-migration-check";
|
||||
li.textContent = "✅ Migration password noted";
|
||||
checklist.appendChild(li);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
function showMigrationStep(password) {
|
||||
for (var i = 1; i <= TOTAL_STEPS; i++) {
|
||||
var panel = document.getElementById("step-" + i);
|
||||
if (panel) panel.style.display = "none";
|
||||
}
|
||||
var migrationPanel = document.getElementById("step-migration");
|
||||
if (migrationPanel) migrationPanel.style.display = "";
|
||||
var pw = document.getElementById("migration-password-value");
|
||||
if (pw) pw.textContent = password || "";
|
||||
var progressBar = document.getElementById("onboarding-progress-bar");
|
||||
if (progressBar) progressBar.style.display = "none";
|
||||
var nav = document.getElementById("onboarding-steps-nav");
|
||||
if (nav) nav.style.display = "none";
|
||||
}
|
||||
|
||||
function showStep1FromMigration() {
|
||||
var migrationPanel = document.getElementById("step-migration");
|
||||
if (migrationPanel) migrationPanel.style.display = "none";
|
||||
var progressBar = document.getElementById("onboarding-progress-bar");
|
||||
if (progressBar) progressBar.style.display = "";
|
||||
var nav = document.getElementById("onboarding-steps-nav");
|
||||
if (nav) nav.style.display = "";
|
||||
showStep(1);
|
||||
loadStep1();
|
||||
}
|
||||
|
||||
// ── Progress / step navigation ────────────────────────────────────
|
||||
|
||||
function updateProgress(step) {
|
||||
@@ -91,6 +135,8 @@ function showStep(step) {
|
||||
// Lazy-load step content
|
||||
if (step === 2) loadStep2();
|
||||
if (step === 3) loadStep3();
|
||||
if (step === 4) loadStep4();
|
||||
// Step 5 (Complete) is static — no lazy-load needed
|
||||
}
|
||||
|
||||
// Return the next step number, skipping over role-excluded steps
|
||||
@@ -119,10 +165,157 @@ async function loadStep1() {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Step 2: Domain Configuration ─────────────────────────────────
|
||||
// ── Step 2: Timezone & Locale ─────────────────────────────────────
|
||||
|
||||
async function loadStep2() {
|
||||
var body = document.getElementById("step-2-body");
|
||||
if (!body || body._tzLoaded) return;
|
||||
body._tzLoaded = true;
|
||||
|
||||
body.innerHTML = '<p class="onboarding-loading">Loading timezone data…</p>';
|
||||
|
||||
var timezones = [];
|
||||
var currentTz = null;
|
||||
var locales = [];
|
||||
var currentLocale = null;
|
||||
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
apiFetch("/api/system/timezones"),
|
||||
apiFetch("/api/system/locales"),
|
||||
]);
|
||||
timezones = results[0].timezones || [];
|
||||
currentTz = results[0].current || null;
|
||||
locales = results[1].locales || [];
|
||||
currentLocale = results[1].current || null;
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load timezone data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to auto-detect timezone from browser
|
||||
var browserTz = null;
|
||||
try { browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) {}
|
||||
var selectedTz = currentTz || browserTz || "";
|
||||
|
||||
var html = '';
|
||||
|
||||
// Timezone section
|
||||
html += '<div class="onboarding-tz-group">';
|
||||
html += '<label class="onboarding-domain-label" for="tz-search">🕐 Timezone</label>';
|
||||
html += '<input class="onboarding-tz-search" type="text" id="tz-search" placeholder="Search timezones…" autocomplete="off" value="' + escHtml(selectedTz) + '" />';
|
||||
html += '<select class="onboarding-tz-select" id="tz-select" size="5">';
|
||||
timezones.forEach(function(tz) {
|
||||
var sel = tz === selectedTz ? ' selected' : '';
|
||||
html += '<option value="' + escHtml(tz) + '"' + sel + '>' + escHtml(tz) + '</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
html += '<p class="onboarding-hint">Current: <span id="tz-current-display">' + escHtml(selectedTz || 'Not set') + '</span></p>';
|
||||
html += '</div>';
|
||||
|
||||
// Locale section
|
||||
html += '<div class="onboarding-tz-group">';
|
||||
html += '<label class="onboarding-domain-label" for="locale-select">🌐 Language / Locale</label>';
|
||||
html += '<select class="onboarding-tz-select onboarding-locale-select" id="locale-select">';
|
||||
locales.forEach(function(loc) {
|
||||
var sel = loc === (currentLocale || 'en_US.UTF-8') ? ' selected' : '';
|
||||
html += '<option value="' + escHtml(loc) + '"' + sel + '>' + escHtml(loc) + '</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
html += '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
// Wire timezone search filter
|
||||
var tzSearch = document.getElementById('tz-search');
|
||||
var tzSelect = document.getElementById('tz-select');
|
||||
var tzCurrentDisplay = document.getElementById('tz-current-display');
|
||||
|
||||
if (tzSearch && tzSelect) {
|
||||
// When typing in search box, filter the dropdown options
|
||||
tzSearch.addEventListener('input', function() {
|
||||
var q = tzSearch.value.toLowerCase();
|
||||
var opts = tzSelect.options;
|
||||
var firstVisible = null;
|
||||
for (var i = 0; i < opts.length; i++) {
|
||||
var match = opts[i].value.toLowerCase().indexOf(q) !== -1;
|
||||
opts[i].style.display = match ? '' : 'none';
|
||||
if (match && firstVisible === null) firstVisible = i;
|
||||
}
|
||||
if (firstVisible !== null) {
|
||||
tzSelect.selectedIndex = firstVisible;
|
||||
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.options[firstVisible].value;
|
||||
}
|
||||
});
|
||||
|
||||
// When selecting from dropdown, update search box
|
||||
tzSelect.addEventListener('change', function() {
|
||||
if (tzSelect.value) {
|
||||
tzSearch.value = tzSelect.value;
|
||||
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll selected option into view
|
||||
if (tzSelect.selectedIndex >= 0) {
|
||||
tzSelect.options[tzSelect.selectedIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStep2() {
|
||||
var tzSelect = document.getElementById('tz-select');
|
||||
var tzSearch = document.getElementById('tz-search');
|
||||
var localeSelect = document.getElementById('locale-select');
|
||||
|
||||
// Determine selected timezone: prefer dropdown selection, fall back to search text
|
||||
var tz = (tzSelect && tzSelect.value) ? tzSelect.value : (tzSearch ? tzSearch.value.trim() : '');
|
||||
var locale = localeSelect ? localeSelect.value : '';
|
||||
|
||||
if (!tz) {
|
||||
setStatus('step-2-status', '⚠ Please select a timezone.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus('step-2-status', 'Saving…', 'info');
|
||||
|
||||
var errors = [];
|
||||
|
||||
try {
|
||||
await apiFetch('/api/system/timezone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ timezone: tz }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push('Timezone: ' + err.message);
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
try {
|
||||
await apiFetch('/api/system/locale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale: locale }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push('Locale: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setStatus('step-2-status', '⚠ ' + errors.join('; '), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus('step-2-status', '✓ Timezone & locale saved', 'ok');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 3: Domain Configuration ─────────────────────────────────
|
||||
|
||||
async function loadStep3() {
|
||||
var body = document.getElementById("step-3-body");
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
@@ -140,8 +333,6 @@ async function loadStep2() {
|
||||
return;
|
||||
}
|
||||
|
||||
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
|
||||
|
||||
// Build set of enabled service units
|
||||
var enabledUnits = new Set();
|
||||
(_servicesData || []).forEach(function(svc) {
|
||||
@@ -159,26 +350,34 @@ async function loadStep2() {
|
||||
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||
} else {
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '<strong>Before you continue:</strong>'
|
||||
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
|
||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>'
|
||||
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>'
|
||||
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
|
||||
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>'
|
||||
+ '<li>Njal.la will give you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>'
|
||||
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
|
||||
+ '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
|
||||
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
|
||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
||||
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
|
||||
+ '(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>.)<br>'
|
||||
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
|
||||
+ '⚠ Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
|
||||
+ '<li>A Dynamic record has NO IP field. You don't enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
|
||||
+ '</ul>'
|
||||
+ '</li>'
|
||||
+ '<li>Njal.la gives you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>'
|
||||
+ '</ol>'
|
||||
+ '</div>';
|
||||
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.com</code>) and its Njal.la DDNS curl command.</p>';
|
||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
||||
relevantDomains.forEach(function(d) {
|
||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||
html += '<div class="onboarding-domain-group">';
|
||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=' + escHtml(d.name) + '.yourdomain.com&k=abc123&auto"" />';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
|
||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
@@ -189,13 +388,82 @@ async function loadStep2() {
|
||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
html += '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
// Wire per-field save buttons for domains
|
||||
body.querySelectorAll('[data-save-domain]').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
var domainName = btn.dataset.saveDomain;
|
||||
var domainInput = document.getElementById('domain-input-' + domainName);
|
||||
var ddnsInput = document.getElementById('ddns-input-' + domainName);
|
||||
var statusEl = document.getElementById('domain-save-status-' + domainName);
|
||||
var domainVal = domainInput ? domainInput.value.trim() : '';
|
||||
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : '';
|
||||
|
||||
if (!domainVal) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ Enter a domain first'; statusEl.style.color = 'var(--red)'; }
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
if (statusEl) { statusEl.textContent = ''; }
|
||||
|
||||
try {
|
||||
await apiFetch('/api/domains/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
|
||||
});
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
});
|
||||
|
||||
// Wire save button for SSL email
|
||||
body.querySelectorAll('[data-save-email]').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
var emailInput = document.getElementById('ssl-email-input');
|
||||
var statusEl = document.getElementById('domain-save-status-email');
|
||||
var emailVal = emailInput ? emailInput.value.trim() : '';
|
||||
|
||||
if (!emailVal) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ Enter an email first'; statusEl.style.color = 'var(--red)'; }
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
if (statusEl) { statusEl.textContent = ''; }
|
||||
|
||||
try {
|
||||
await apiFetch('/api/domains/set-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailVal }),
|
||||
});
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveStep2() {
|
||||
setStatus("step-2-status", "Saving domains…", "info");
|
||||
async function saveStep3() {
|
||||
setStatus("step-3-status", "Saving domains…", "info");
|
||||
var errors = [];
|
||||
|
||||
// Save each domain input
|
||||
@@ -235,20 +503,20 @@ async function saveStep2() {
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setStatus("step-2-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus("step-2-status", "✓ Saved", "ok");
|
||||
setStatus("step-3-status", "✓ Saved", "ok");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 3: Port Forwarding ───────────────────────────────────────
|
||||
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
||||
|
||||
async function loadStep3() {
|
||||
var body = document.getElementById("step-3-body");
|
||||
async function loadStep4() {
|
||||
var body = document.getElementById("step-4-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||
body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>';
|
||||
|
||||
var networkData = null;
|
||||
|
||||
@@ -259,51 +527,59 @@ async function loadStep3() {
|
||||
return;
|
||||
}
|
||||
|
||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
||||
|
||||
var ip = escHtml(internalIp);
|
||||
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
|
||||
var internalIp = trimmedInternalIp || "";
|
||||
var hasInternalIp = !!internalIp;
|
||||
var ip = escHtml(internalIp || "Could not detect");
|
||||
var routerIpHelp = hasInternalIp
|
||||
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
|
||||
: "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule.";
|
||||
var destinationInstruction = hasInternalIp
|
||||
? 'Set the destination/internal IP to <strong>' + ip + '</strong>'
|
||||
: 'Use this computer’s internal IP as the destination/internal IP';
|
||||
|
||||
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
||||
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||
+ '</p>';
|
||||
|
||||
html += '<div class="onboarding-port-ip">';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
|
||||
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
|
||||
|
||||
// Required ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Router Rules</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward To</th><th>Used For</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP</td></tr>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP / SSL setup</td></tr>';
|
||||
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">SSH Remote Access</td></tr>';
|
||||
html += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Remote SSH access</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Optional ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">These 5 additional port openings are required on top of the 4 required ports above.</div>';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward To</th><th>Used For</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
|
||||
html += '<tr><td class="port-req-port">7882–7894</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media streams</td></tr>';
|
||||
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000–40000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000-40000</td><td class="port-req-proto">TCP & UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '<div style="font-size:0.85em;margin-top:6px;color:var(--color-text-muted,#888);">ℹ The <strong>30000-40000</strong> range is a single forwarding rule — just set its protocol to <strong>both TCP and UDP</strong> (often shown as "Both" or "TCP/UDP" on your router).</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Totals
|
||||
html += '<div class="onboarding-port-totals">';
|
||||
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
|
||||
html += '<strong>Total port openings: 9</strong> (with Element Calling — 4 required + 5 optional)';
|
||||
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
|
||||
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
@@ -318,19 +594,23 @@ async function loadStep3() {
|
||||
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
||||
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
||||
+ '<li>Create a new rule for each port listed above</li>'
|
||||
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
|
||||
+ '<li>' + destinationInstruction + '</li>'
|
||||
+ '<li>Set both internal and external port to the same number</li>'
|
||||
+ '<li>Save and apply changes</li>'
|
||||
+ '</ol>'
|
||||
+ '</details>';
|
||||
|
||||
html += '<div class="onboarding-port-note" style="margin-top:12px;">'
|
||||
+ '<strong>Important:</strong> The Hub can show which ports Sovran_SystemsOS needs, but it cannot fully confirm router forwarding from inside your home network. Full public port verification requires an outside internet check.'
|
||||
+ '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Step 4: Complete ──────────────────────────────────────────────
|
||||
// ── Step 5: Complete ──────────────────────────────────────────────
|
||||
|
||||
async function completeOnboarding() {
|
||||
var btn = document.getElementById("step-4-finish");
|
||||
var btn = document.getElementById("step-5-finish");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||
|
||||
try {
|
||||
@@ -345,28 +625,57 @@ async function completeOnboarding() {
|
||||
// ── Event wiring ──────────────────────────────────────────────────
|
||||
|
||||
function wireNavButtons() {
|
||||
// Step 1 → next (may skip 2+3 for desktop/node)
|
||||
var migrationContinue = document.getElementById("migration-password-continue");
|
||||
if (migrationContinue) migrationContinue.addEventListener("click", async function() {
|
||||
migrationContinue.disabled = true;
|
||||
migrationContinue.textContent = "Continuing…";
|
||||
setStatus("migration-password-status", "Saving acknowledgement…", "info");
|
||||
try {
|
||||
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
|
||||
_migrationOccurred = true;
|
||||
updateStep5Checklist();
|
||||
showStep1FromMigration();
|
||||
} catch (err) {
|
||||
setStatus("migration-password-status", "⚠ " + err.message, "error");
|
||||
migrationContinue.disabled = false;
|
||||
migrationContinue.textContent = "I've written it down — Continue →";
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1 → next
|
||||
var s1next = document.getElementById("step-1-next");
|
||||
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
|
||||
|
||||
// Step 2 → 3 (save first)
|
||||
// Step 2 → 3 (save timezone/locale first)
|
||||
var s2next = document.getElementById("step-2-next");
|
||||
if (s2next) s2next.addEventListener("click", async function() {
|
||||
s2next.disabled = true;
|
||||
var origText = s2next.textContent;
|
||||
s2next.textContent = "Saving…";
|
||||
await saveStep2();
|
||||
var ok = await saveStep2();
|
||||
s2next.disabled = false;
|
||||
s2next.textContent = "Save & Continue →";
|
||||
showStep(nextStep(2));
|
||||
s2next.textContent = origText;
|
||||
if (ok) showStep(nextStep(2));
|
||||
});
|
||||
|
||||
// Step 3 → 4 (Complete)
|
||||
// Step 3 → 4 (save domains first)
|
||||
var s3next = document.getElementById("step-3-next");
|
||||
if (s3next) s3next.addEventListener("click", function() { showStep(nextStep(3)); });
|
||||
if (s3next) s3next.addEventListener("click", async function() {
|
||||
s3next.disabled = true;
|
||||
s3next.textContent = "Saving…";
|
||||
await saveStep3();
|
||||
s3next.disabled = false;
|
||||
s3next.textContent = "Save & Continue →";
|
||||
showStep(nextStep(3));
|
||||
});
|
||||
|
||||
// Step 4: finish
|
||||
var s4finish = document.getElementById("step-4-finish");
|
||||
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
|
||||
// Step 4 → 5 (port forwarding — no save needed)
|
||||
var s4next = document.getElementById("step-4-next");
|
||||
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
|
||||
|
||||
// Step 5: finish
|
||||
var s5finish = document.getElementById("step-5-finish");
|
||||
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
|
||||
|
||||
// Back buttons
|
||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||
@@ -394,6 +703,17 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
} catch (_) {}
|
||||
|
||||
wireNavButtons();
|
||||
updateProgress(1);
|
||||
|
||||
try {
|
||||
var migration = await apiFetch("/api/migration/password-status");
|
||||
if (migration && migration.pending) {
|
||||
updateStep5Checklist();
|
||||
showMigrationStep(migration.password || "");
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
updateStep5Checklist();
|
||||
showStep(1);
|
||||
loadStep1();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#153126"/>
|
||||
<stop offset="55%" stop-color="#0F241B"/>
|
||||
<stop offset="100%" stop-color="#091C14"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#42F39A"/>
|
||||
<stop offset="45%" stop-color="#28D978"/>
|
||||
<stop offset="100%" stop-color="#1AA45D"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#27C86F"/>
|
||||
<stop offset="100%" stop-color="#157E49"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 .18 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
|
||||
fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
|
||||
fill="none" filter="url(#innerShade)"/>
|
||||
|
||||
<path d="M128 32 A96 96 0 1 1 58 196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<path d="M128 56 A72 72 0 1 1 76 178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
|
||||
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -4,25 +4,28 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS Hub</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||
<link rel="stylesheet" href="/static/css/header.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/features.css" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||
<link rel="stylesheet" href="/static/css/support.css" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header bar -->
|
||||
<header class="header-bar">
|
||||
<img src="/static/logo-light.svg" alt="Sovran Systems" class="header-logo" />
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
<div class="header-buttons">
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -59,7 +62,7 @@
|
||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +165,7 @@
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +186,7 @@
|
||||
<div class="upgrade-info-box">
|
||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
||||
<ul class="upgrade-info-list">
|
||||
<li>You will need to purchase domains for your services (we recommend <a href="https://njal.la" target="_blank" rel="noopener noreferrer">njal.la</a>)</li>
|
||||
<li>You will need to purchase domains for your services — <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
|
||||
<li>Some services require ports to be opened on your router</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -209,33 +212,103 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reboot overlay -->
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<div class="reboot-card">
|
||||
<div class="reboot-icon">↻</div>
|
||||
<h2 class="reboot-title">System Rebooting</h2>
|
||||
<!-- Security Reset overlay -->
|
||||
<div class="security-reset-overlay" id="security-reset-overlay">
|
||||
<!-- Phase 1: wiping in progress -->
|
||||
<div class="reboot-card" id="security-reset-phase1">
|
||||
<div class="security-reset-overlay-icon">🛡</div>
|
||||
<h2 class="reboot-title">Security Reset In Progress</h2>
|
||||
<p class="reboot-message">
|
||||
Sovran_SystemsOS is now restarting.<br />
|
||||
This page will automatically reconnect once the system is back online.
|
||||
⚠️ Wiping all data and credentials.<br />
|
||||
<strong>Do not power off your computer.</strong><br />
|
||||
This may take several minutes.
|
||||
</p>
|
||||
<div class="reboot-dots">
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
</div>
|
||||
<p class="reboot-submessage">Stay tuned…</p>
|
||||
<p class="reboot-submessage" id="security-reset-overlay-step">Erasing data and resetting credentials…</p>
|
||||
</div>
|
||||
<!-- Phase 2: password display -->
|
||||
<div class="reboot-card" id="security-reset-phase2" style="display:none;">
|
||||
<div class="security-reset-overlay-icon">🔑</div>
|
||||
<h2 class="reboot-title">Security Reset Complete</h2>
|
||||
<p class="security-reset-password-label">Your new login password is:</p>
|
||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||
<p class="security-reset-password-warning">
|
||||
✍️ <strong>Write this down now.</strong><br />
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Restart Entire System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/constants.js"></script>
|
||||
<script src="/static/js/state.js"></script>
|
||||
<script src="/static/js/helpers.js"></script>
|
||||
<script src="/static/js/tiles.js"></script>
|
||||
<script src="/static/js/service-detail.js"></script>
|
||||
<script src="/static/js/support.js"></script>
|
||||
<script src="/static/js/update.js"></script>
|
||||
<script src="/static/js/rebuild.js"></script>
|
||||
<script src="/static/js/features.js"></script>
|
||||
<script src="/static/js/events.js"></script>
|
||||
<!-- Reboot overlay -->
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<!-- Normal restarting card -->
|
||||
<div class="reboot-card" id="reboot-main-card">
|
||||
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||
<p class="reboot-message">
|
||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||
This page will reconnect automatically when Sovran_SystemsOS is back online.
|
||||
</p>
|
||||
<div class="reboot-dots" aria-hidden="true">
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
</div>
|
||||
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request…</p>
|
||||
</div>
|
||||
<!-- Error card (shown if restart request fails definitively) -->
|
||||
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||
<div class="reboot-icon" aria-hidden="true">⚠</div>
|
||||
<h2 class="reboot-title">Restart could not be started</h2>
|
||||
<p class="reboot-message">
|
||||
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||
</p>
|
||||
<div class="reboot-error-actions">
|
||||
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
|
||||
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Confirm Dialog -->
|
||||
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
|
||||
<p class="restart-conflict-title">The system cannot restart right now.</p>
|
||||
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
|
||||
</div>
|
||||
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
|
||||
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
|
||||
<p class="support-desc">The system usually returns within 1–3 minutes. This page will reconnect automatically.</p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/state.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/support.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/update.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran Hub — Login</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
|
||||
<div class="login-title">Sovran Hub</div>
|
||||
</div>
|
||||
|
||||
<form class="login-form" id="login-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="Enter your Hub password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="login-error" id="login-error">Incorrect password. Please try again.</div>
|
||||
|
||||
<button type="submit" class="btn btn-login" id="btn-login">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('login-form');
|
||||
var input = document.getElementById('password');
|
||||
var errEl = document.getElementById('login-error');
|
||||
var btnEl = document.getElementById('btn-login');
|
||||
|
||||
form.addEventListener('submit', function () {
|
||||
var password = input.value;
|
||||
if (!password) return;
|
||||
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = 'Signing in…';
|
||||
errEl.classList.remove('visible');
|
||||
|
||||
fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password }),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
window.location.replace('/');
|
||||
} else {
|
||||
return res.json().then(function (data) {
|
||||
errEl.textContent = (data && data.detail) ? data.detail : 'Incorrect password. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = 'Sign In';
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
errEl.textContent = 'Network error. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = 'Sign In';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="onboarding-shell">
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="onboarding-progress-bar">
|
||||
<div class="onboarding-progress-bar" id="onboarding-progress-bar">
|
||||
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
|
||||
</div>
|
||||
|
||||
@@ -34,11 +34,40 @@
|
||||
<span class="onboarding-step-dot" data-step="3">3</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="5">5</span>
|
||||
</div>
|
||||
|
||||
<!-- Step panels -->
|
||||
<div class="onboarding-panel-wrap">
|
||||
|
||||
<!-- ── Migration Password Gate (pre-step) ── -->
|
||||
<div class="onboarding-panel" id="step-migration" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">🔐</div>
|
||||
<h1 class="onboarding-title">Your system has been migrated to Sovran_SystemsOS</h1>
|
||||
<p class="onboarding-subtitle">Important password update required</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
|
||||
Your new login password is:
|
||||
</p>
|
||||
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(109, 191, 139, 0.10); border:1.5px solid rgba(109, 191, 139, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
|
||||
|
||||
</div>
|
||||
<div style="padding:10px 14px; background-color:rgba(229, 165, 10, 0.1); border:1px solid rgba(229, 165, 10, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
|
||||
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
|
||||
</div>
|
||||
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<div></div>
|
||||
<button class="btn btn-primary" id="migration-password-continue">
|
||||
I've written it down — Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 1: Welcome ── -->
|
||||
<div class="onboarding-panel" id="step-1">
|
||||
<div class="onboarding-hero">
|
||||
@@ -70,20 +99,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Domain Configuration ── -->
|
||||
<!-- ── Step 2: Timezone & Locale ── -->
|
||||
<div class="onboarding-panel" id="step-2" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<span class="onboarding-step-icon">🌍</span>
|
||||
<h2 class="onboarding-step-title">Timezone & Locale</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
||||
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
|
||||
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
|
||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||
Select your timezone and preferred language so your system clock, logs,
|
||||
and services display the correct time and format.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card onboarding-card--scroll" id="step-2-body">
|
||||
<p class="onboarding-loading">Loading service information…</p>
|
||||
<div class="onboarding-card" id="step-2-body">
|
||||
<p class="onboarding-loading">Loading timezone data…</p>
|
||||
</div>
|
||||
<div id="step-2-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
@@ -94,29 +121,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 3: Port Forwarding ── -->
|
||||
<!-- ── Step 3: Domain Configuration ── -->
|
||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
|
||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
||||
Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
|
||||
Paste the curl command from your Njal.la dashboard for each service.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card onboarding-card--ports" id="step-3-body">
|
||||
<p class="onboarding-loading">Checking ports…</p>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
<p class="onboarding-loading">Loading service information…</p>
|
||||
</div>
|
||||
<div id="step-3-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
||||
Save & Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Port Forwarding ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Router Setup</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network.
|
||||
<strong>Ports 80 and 443 are required for HTTPS and SSL certificates.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-4-body">
|
||||
<p class="onboarding-loading">Loading router setup…</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<!-- ── Step 5: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">✅</div>
|
||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||
@@ -128,13 +178,14 @@
|
||||
monitor your services, manage credentials, and make changes at any time.
|
||||
</p>
|
||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||
<li>✅ Timezone & locale configured</li>
|
||||
<li>✅ Domain configuration saved</li>
|
||||
<li>✅ Port forwarding reviewed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary" id="step-4-finish">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||
<button class="btn btn-primary" id="step-5-finish">
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
@@ -145,4 +196,4 @@
|
||||
|
||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def _install_web_stubs():
|
||||
if "fastapi" in sys.modules:
|
||||
return
|
||||
|
||||
class _HTTPException(Exception):
|
||||
def __init__(self, status_code=None, detail=None):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
class _FastAPI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def mount(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def add_middleware(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def __getattr__(self, _name):
|
||||
def _decorator_factory(*args, **kwargs):
|
||||
def _decorator(func):
|
||||
return func
|
||||
|
||||
return _decorator
|
||||
|
||||
return _decorator_factory
|
||||
|
||||
class _BaseModel:
|
||||
pass
|
||||
|
||||
class _StaticFiles:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _Jinja2Templates:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _BaseHTTPMiddleware:
|
||||
pass
|
||||
|
||||
fastapi_module = types.ModuleType("fastapi")
|
||||
fastapi_module.FastAPI = _FastAPI
|
||||
fastapi_module.HTTPException = _HTTPException
|
||||
sys.modules["fastapi"] = fastapi_module
|
||||
|
||||
responses_module = types.ModuleType("fastapi.responses")
|
||||
responses_module.HTMLResponse = object
|
||||
responses_module.JSONResponse = object
|
||||
responses_module.RedirectResponse = object
|
||||
sys.modules["fastapi.responses"] = responses_module
|
||||
|
||||
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||
staticfiles_module.StaticFiles = _StaticFiles
|
||||
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||
|
||||
templating_module = types.ModuleType("fastapi.templating")
|
||||
templating_module.Jinja2Templates = _Jinja2Templates
|
||||
sys.modules["fastapi.templating"] = templating_module
|
||||
|
||||
requests_module = types.ModuleType("fastapi.requests")
|
||||
requests_module.Request = object
|
||||
sys.modules["fastapi.requests"] = requests_module
|
||||
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
pydantic_module.BaseModel = _BaseModel
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||
|
||||
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||
starlette_middleware_module.base = starlette_base_module
|
||||
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||
|
||||
starlette_module = types.ModuleType("starlette")
|
||||
starlette_module.middleware = starlette_middleware_module
|
||||
sys.modules["starlette"] = starlette_module
|
||||
|
||||
|
||||
_install_web_stubs()
|
||||
from sovran_systemsos_web import server
|
||||
|
||||
|
||||
class Bip110StatusTests(unittest.TestCase):
|
||||
def _status(self, deploy_info, net_info):
|
||||
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
|
||||
server, "_get_bitcoin_version_info", return_value=net_info
|
||||
):
|
||||
return server._get_bip110_status()
|
||||
|
||||
def test_started_reduced_data_reports_signaling(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"reduced_data": {
|
||||
"type": "bip9",
|
||||
"active": False,
|
||||
"bip9": {
|
||||
"bit": 4,
|
||||
"status": "started",
|
||||
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
|
||||
"signalling": "--#--",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
|
||||
)
|
||||
|
||||
def test_active_reduced_data_reports_active(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "active")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_locked_in_reduced_data_reports_locked_in(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "locked_in")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
|
||||
}
|
||||
}
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
|
||||
)
|
||||
|
||||
def test_node_unreachable_reports_unknown(self):
|
||||
result = self._status(None, None)
|
||||
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HUB_NIX = Path(__file__).resolve().parents[2] / "modules" / "core" / "sovran-hub.nix"
|
||||
|
||||
|
||||
def _section(source: str, start: str, end: str) -> str:
|
||||
start_idx = source.find(start)
|
||||
if start_idx == -1:
|
||||
raise AssertionError(f"Expected section start not found: {start!r}")
|
||||
end_idx = source.find(end, start_idx)
|
||||
if end_idx == -1:
|
||||
raise AssertionError(f"Expected section end not found: {end!r}")
|
||||
return source[start_idx:end_idx]
|
||||
|
||||
|
||||
class HubUpdateBootStagingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = HUB_NIX.read_text()
|
||||
self.update_section = _section(
|
||||
self.source,
|
||||
'update-script = pkgs.writeShellScript "sovran-hub-update.sh" \'\'',
|
||||
"# ── Rebuild wrapper script",
|
||||
)
|
||||
self.rebuild_section = _section(
|
||||
self.source,
|
||||
'rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" \'\'',
|
||||
"# ── Brave launcher wrapper",
|
||||
)
|
||||
|
||||
def test_full_update_uses_boot_not_switch(self):
|
||||
self.assertIn("nixos-rebuild boot --flake /etc/nixos", self.update_section)
|
||||
self.assertNotIn("nixos-rebuild switch --flake /etc/nixos", self.update_section)
|
||||
|
||||
def test_full_update_marks_reboot_required(self):
|
||||
self.assertIn('echo "REBOOT_REQUIRED" > "$STATUS"', self.update_section)
|
||||
|
||||
def test_rebuild_path_keeps_switch_semantics(self):
|
||||
self.assertIn("nixos-rebuild switch --flake /etc/nixos", self.rebuild_section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Tests for server-local loopback diagnostics and domain validation.
|
||||
|
||||
Covers:
|
||||
- Domain value validation and injection prevention.
|
||||
- Loopback address detection (IPv4 and IPv6).
|
||||
- _resolve_all_addresses returning multiple addresses.
|
||||
- _check_domain_health_fast with loopback resolution.
|
||||
- _evaluate_domain_checklist with loopback override — no false dns_mismatch.
|
||||
- _evaluate_domain_checklist with genuine DNS mismatch — still reports error.
|
||||
- api_services health stays "healthy" when domain resolves to loopback.
|
||||
- api_services health stays "needs_attention" when DNS is genuinely wrong.
|
||||
- api_domains_check returns "local_override" for loopback-resolved domains.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stubs so server.py can be imported without the full FastAPI stack.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_web_stubs():
|
||||
if "fastapi" in sys.modules:
|
||||
return
|
||||
|
||||
class _HTTPException(Exception):
|
||||
def __init__(self, status_code=None, detail=None):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
class _FastAPI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def mount(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def add_middleware(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def __getattr__(self, _name):
|
||||
def _decorator_factory(*args, **kwargs):
|
||||
def _decorator(func):
|
||||
return func
|
||||
return _decorator
|
||||
return _decorator_factory
|
||||
|
||||
class _BaseModel:
|
||||
pass
|
||||
|
||||
class _StaticFiles:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _Jinja2Templates:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _BaseHTTPMiddleware:
|
||||
pass
|
||||
|
||||
fastapi_module = types.ModuleType("fastapi")
|
||||
fastapi_module.FastAPI = _FastAPI
|
||||
fastapi_module.HTTPException = _HTTPException
|
||||
sys.modules["fastapi"] = fastapi_module
|
||||
|
||||
responses_module = types.ModuleType("fastapi.responses")
|
||||
responses_module.HTMLResponse = object
|
||||
responses_module.JSONResponse = object
|
||||
responses_module.RedirectResponse = object
|
||||
sys.modules["fastapi.responses"] = responses_module
|
||||
|
||||
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||
staticfiles_module.StaticFiles = _StaticFiles
|
||||
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||
|
||||
templating_module = types.ModuleType("fastapi.templating")
|
||||
templating_module.Jinja2Templates = _Jinja2Templates
|
||||
sys.modules["fastapi.templating"] = templating_module
|
||||
|
||||
requests_module = types.ModuleType("fastapi.requests")
|
||||
requests_module.Request = object
|
||||
sys.modules["fastapi.requests"] = requests_module
|
||||
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
pydantic_module.BaseModel = _BaseModel
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||
|
||||
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||
starlette_middleware_module.base = starlette_base_module
|
||||
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||
|
||||
starlette_module = types.ModuleType("starlette")
|
||||
starlette_module.middleware = starlette_middleware_module
|
||||
sys.modules["starlette"] = starlette_module
|
||||
|
||||
|
||||
_install_web_stubs()
|
||||
from sovran_systemsos_web import server # noqa: E402
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Domain value validation
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateDomainValue(unittest.TestCase):
|
||||
"""_validate_domain_value must reject anything that could corrupt /etc/hosts."""
|
||||
|
||||
def _v(self, value: str) -> bool:
|
||||
return server._validate_domain_value(value)
|
||||
|
||||
# -- Valid values --------------------------------------------------------
|
||||
|
||||
def test_simple_domain_valid(self):
|
||||
self.assertTrue(self._v("cloud.example.com"))
|
||||
|
||||
def test_subdomain_valid(self):
|
||||
self.assertTrue(self._v("matrix.home.example.org"))
|
||||
|
||||
def test_single_label_with_tld_valid(self):
|
||||
self.assertTrue(self._v("example.com"))
|
||||
|
||||
def test_hyphen_in_domain_valid(self):
|
||||
self.assertTrue(self._v("my-nextcloud.example.com"))
|
||||
|
||||
# -- Injection / malformed values ----------------------------------------
|
||||
|
||||
def test_empty_string_invalid(self):
|
||||
self.assertFalse(self._v(""))
|
||||
|
||||
def test_newline_injection_invalid(self):
|
||||
self.assertFalse(self._v("evil.com\n127.0.0.1 other.host"))
|
||||
|
||||
def test_carriage_return_injection_invalid(self):
|
||||
self.assertFalse(self._v("evil.com\r127.0.0.1 other.host"))
|
||||
|
||||
def test_space_injection_invalid(self):
|
||||
self.assertFalse(self._v("evil.com 127.0.0.1"))
|
||||
|
||||
def test_hash_comment_injection_invalid(self):
|
||||
self.assertFalse(self._v("evil.com# comment"))
|
||||
|
||||
def test_bare_hostname_no_dot_invalid(self):
|
||||
self.assertFalse(self._v("localhost"))
|
||||
|
||||
def test_bare_ip_invalid(self):
|
||||
self.assertFalse(self._v("192.168.1.1"))
|
||||
|
||||
def test_too_long_invalid(self):
|
||||
self.assertFalse(self._v("a" * 254 + ".com"))
|
||||
|
||||
def test_leading_dot_invalid(self):
|
||||
self.assertFalse(self._v(".example.com"))
|
||||
|
||||
def test_trailing_dot_invalid(self):
|
||||
self.assertFalse(self._v("example.com."))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Loopback address detection
|
||||
# ===========================================================================
|
||||
|
||||
class TestIsLoopbackAddress(unittest.TestCase):
|
||||
|
||||
def test_ipv4_loopback(self):
|
||||
self.assertTrue(server._is_loopback_address("127.0.0.1"))
|
||||
|
||||
def test_ipv4_loopback_other(self):
|
||||
self.assertTrue(server._is_loopback_address("127.0.0.2"))
|
||||
|
||||
def test_ipv4_loopback_high(self):
|
||||
self.assertTrue(server._is_loopback_address("127.255.255.255"))
|
||||
|
||||
def test_ipv6_loopback(self):
|
||||
self.assertTrue(server._is_loopback_address("::1"))
|
||||
|
||||
def test_public_ipv4_not_loopback(self):
|
||||
self.assertFalse(server._is_loopback_address("203.0.113.10"))
|
||||
|
||||
def test_private_ipv4_not_loopback(self):
|
||||
self.assertFalse(server._is_loopback_address("192.168.1.50"))
|
||||
|
||||
def test_ipv6_public_not_loopback(self):
|
||||
self.assertFalse(server._is_loopback_address("2001:db8::1"))
|
||||
|
||||
def test_invalid_string_not_loopback(self):
|
||||
self.assertFalse(server._is_loopback_address("not-an-ip"))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _check_domain_health_fast
|
||||
# ===========================================================================
|
||||
|
||||
class TestCheckDomainHealthFast(unittest.TestCase):
|
||||
"""_check_domain_health_fast returns True when there is an issue,
|
||||
False when everything looks fine."""
|
||||
|
||||
def _fast(self, domain, external_ip, resolved_addrs):
|
||||
with patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs):
|
||||
return server._check_domain_health_fast(domain, external_ip)
|
||||
|
||||
def test_no_domain_no_issue(self):
|
||||
# None/empty domain: the fast check reports True (handled by checklist).
|
||||
result = server._check_domain_health_fast(None, "203.0.113.10")
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_empty_domain_no_issue(self):
|
||||
result = server._check_domain_health_fast("", "203.0.113.10")
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_loopback_ipv4_no_issue(self):
|
||||
"""Loopback override must not be flagged as a DNS mismatch."""
|
||||
result = self._fast("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_loopback_ipv6_no_issue(self):
|
||||
result = self._fast("cloud.example.com", "203.0.113.10", ["::1"])
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_matches_external_ip_no_issue(self):
|
||||
result = self._fast("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_mismatch_is_an_issue(self):
|
||||
result = self._fast("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_unavailable_external_ip_no_issue(self):
|
||||
result = self._fast("cloud.example.com", "unavailable", ["198.51.100.1"])
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_multiple_addresses_one_matches_no_issue(self):
|
||||
"""If any resolved address matches external_ip the check should pass."""
|
||||
result = self._fast(
|
||||
"cloud.example.com", "203.0.113.10",
|
||||
["198.51.100.1", "203.0.113.10"],
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _evaluate_domain_checklist — loopback override path
|
||||
# ===========================================================================
|
||||
|
||||
class TestEvaluateDomainChecklistLoopback(unittest.TestCase):
|
||||
|
||||
def _eval(self, domain, external_ip, resolved_addrs, reachable_result=None):
|
||||
with (
|
||||
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
|
||||
patch.object(server, "_check_domain_reachable",
|
||||
return_value=reachable_result or {"reachable": True, "status_code": 200}),
|
||||
):
|
||||
return server._evaluate_domain_checklist(domain, external_ip)
|
||||
|
||||
def test_loopback_dns_step_is_ok_not_error(self):
|
||||
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
|
||||
dns_step = next(s for s in result["domain_check_steps"] if s["step"] == 2)
|
||||
self.assertEqual(dns_step["status"], "ok")
|
||||
self.assertNotIn("mismatch", dns_step.get("detail", "").lower())
|
||||
|
||||
def test_loopback_domain_status_is_local_override(self):
|
||||
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
|
||||
self.assertEqual(result["domain_status"]["status"], "local_override")
|
||||
|
||||
def test_loopback_has_no_issues_when_reachable(self):
|
||||
result = self._eval(
|
||||
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
|
||||
reachable_result={"reachable": True, "status_code": 200},
|
||||
)
|
||||
self.assertFalse(result["has_issues"])
|
||||
|
||||
def test_loopback_has_issues_when_caddy_unreachable(self):
|
||||
"""A loopback override with Caddy down should still report an issue."""
|
||||
result = self._eval(
|
||||
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
|
||||
reachable_result={"reachable": False, "error": "connection refused"},
|
||||
)
|
||||
self.assertTrue(result["has_issues"])
|
||||
|
||||
def test_ipv6_loopback_no_issue(self):
|
||||
result = self._eval("cloud.example.com", "203.0.113.10", ["::1"])
|
||||
self.assertEqual(result["domain_status"]["status"], "local_override")
|
||||
self.assertFalse(result["has_issues"])
|
||||
|
||||
def test_genuine_mismatch_still_reports_error(self):
|
||||
result = self._eval("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
|
||||
self.assertEqual(result["domain_status"]["status"], "dns_mismatch")
|
||||
self.assertTrue(result["has_issues"])
|
||||
|
||||
def test_correct_public_dns_still_reports_ok(self):
|
||||
result = self._eval("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
|
||||
self.assertEqual(result["domain_status"]["status"], "connected")
|
||||
self.assertFalse(result["has_issues"])
|
||||
|
||||
def test_no_domain_has_issues(self):
|
||||
result = self._eval(None, "203.0.113.10", [])
|
||||
self.assertTrue(result["has_issues"])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# api_services — composite health with loopback
|
||||
# ===========================================================================
|
||||
|
||||
class TestApiServicesLoopbackHealth(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def _get_health(self, resolved_addrs, cached_reachable):
|
||||
"""Return the health value for a single domain-requiring service."""
|
||||
service_cfg = {
|
||||
"services": [
|
||||
{"unit": "caddy.service", "icon": "nextcloud", "enabled": True, "type": "system"}
|
||||
]
|
||||
}
|
||||
with (
|
||||
patch.object(server, "load_config", return_value=service_cfg),
|
||||
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
|
||||
patch.object(server.sysctl, "is_active", return_value="active"),
|
||||
patch.dict(server.SERVICE_DOMAIN_MAP, {"caddy.service": "nextcloud"}, clear=False),
|
||||
patch("builtins.open", mock_open(read_data="cloud.example.com\n")),
|
||||
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
|
||||
patch.object(server, "_is_domain_reachable_cached", return_value=cached_reachable),
|
||||
patch.object(server, "_get_listening_ports",
|
||||
return_value={"tcp": {80, 443}, "udp": set()}),
|
||||
patch.object(server, "_get_firewall_allowed_ports",
|
||||
return_value={"tcp": set(), "udp": set()}),
|
||||
patch.object(server, "_cached_external_ip", "203.0.113.10"),
|
||||
):
|
||||
results = await server.api_services()
|
||||
|
||||
return results[0]["health"]
|
||||
|
||||
async def test_loopback_and_reachable_is_healthy(self):
|
||||
"""Loopback override + Caddy reachable → healthy, not needs_attention."""
|
||||
health = await self._get_health(["127.0.0.1"], cached_reachable=True)
|
||||
self.assertEqual(health, "healthy")
|
||||
|
||||
async def test_loopback_and_caddy_down_is_needs_attention(self):
|
||||
"""Loopback override + Caddy unreachable → needs_attention (genuine issue)."""
|
||||
health = await self._get_health(["127.0.0.1"], cached_reachable=False)
|
||||
self.assertEqual(health, "needs_attention")
|
||||
|
||||
async def test_correct_dns_and_reachable_is_healthy(self):
|
||||
health = await self._get_health(["203.0.113.10"], cached_reachable=True)
|
||||
self.assertEqual(health, "healthy")
|
||||
|
||||
async def test_dns_mismatch_is_needs_attention(self):
|
||||
health = await self._get_health(["198.51.100.1"], cached_reachable=True)
|
||||
self.assertEqual(health, "needs_attention")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# api_domains_check — loopback detection
|
||||
# ===========================================================================
|
||||
|
||||
class TestApiDomainsCheckLoopback(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def _check(self, resolved_addrs, external_ip="203.0.113.10"):
|
||||
with (
|
||||
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
|
||||
patch.object(server, "_cached_external_ip", external_ip),
|
||||
):
|
||||
result = await server.api_domains_check(
|
||||
MagicMock(domains=["cloud.example.com"])
|
||||
)
|
||||
return result["domains"][0]
|
||||
|
||||
async def test_loopback_ipv4_returns_local_override(self):
|
||||
result = await self._check(["127.0.0.1"])
|
||||
self.assertEqual(result["status"], "local_override")
|
||||
|
||||
async def test_loopback_ipv6_returns_local_override(self):
|
||||
result = await self._check(["::1"])
|
||||
self.assertEqual(result["status"], "local_override")
|
||||
|
||||
async def test_correct_dns_returns_connected(self):
|
||||
result = await self._check(["203.0.113.10"])
|
||||
self.assertEqual(result["status"], "connected")
|
||||
|
||||
async def test_mismatch_returns_dns_mismatch(self):
|
||||
result = await self._check(["198.51.100.1"])
|
||||
self.assertEqual(result["status"], "dns_mismatch")
|
||||
|
||||
async def test_no_resolution_returns_unresolvable(self):
|
||||
result = await self._check([])
|
||||
self.assertEqual(result["status"], "unresolvable")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix"
|
||||
USERNAME_READ = "USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\""
|
||||
USERNAME_LENGTH_GUARD = "if [ \"''${#USERNAME}\" -gt 32 ]; then"
|
||||
SHORT_PASSWORD_GUARD = 'if [ "\'\'${#PASSWORD}" -lt 8 ]; then'
|
||||
|
||||
|
||||
def _section(source: str, start: str, end: str) -> str:
|
||||
start_idx = source.find(start)
|
||||
if start_idx == -1:
|
||||
raise AssertionError(f"Expected section start not found: {start!r}")
|
||||
end_idx = source.find(end, start_idx)
|
||||
if end_idx == -1:
|
||||
raise AssertionError(f"Expected section end not found: {end!r}")
|
||||
return source[start_idx:end_idx]
|
||||
|
||||
|
||||
class RdpModuleBootSetupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = RDP_NIX.read_text()
|
||||
self.gnome_service = _section(
|
||||
self.source,
|
||||
"systemd.services.gnome-remote-desktop = {",
|
||||
"systemd.tmpfiles.rules = [",
|
||||
)
|
||||
self.setup_service = _section(
|
||||
self.source,
|
||||
"systemd.services.gnome-remote-desktop-setup = {",
|
||||
"};\n}",
|
||||
)
|
||||
|
||||
def test_does_not_redeclare_gnome_remote_desktop_user(self):
|
||||
self.assertNotIn("users.users.gnome-remote-desktop", self.source)
|
||||
self.assertNotIn("createHome = true;", self.source)
|
||||
|
||||
def test_main_service_requires_setup_before_starting(self):
|
||||
self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service)
|
||||
self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
|
||||
self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
|
||||
|
||||
def test_setup_waits_for_configuration_service_and_bounded_timeout(self):
|
||||
self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service)
|
||||
self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service)
|
||||
self.assertIn('"dbus.service"', self.setup_service)
|
||||
self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service)
|
||||
self.assertNotIn("RemainAfterExit", self.setup_service)
|
||||
self.assertIn('TimeoutStartSec = "2min";', self.setup_service)
|
||||
self.assertIn('timeout --kill-after=5s 10s', self.setup_service)
|
||||
self.assertIn('echo "grdctl command timed out: $*" >&2', self.setup_service)
|
||||
self.assertIn('echo "grdctl command failed (exit $rc): $*" >&2', self.setup_service)
|
||||
|
||||
def test_setup_runs_grdctl_directly_as_root(self):
|
||||
# The oneshot service runs as root; grdctl --system is called directly.
|
||||
# GRD 50.x invokes pkexec internally, but the call itself is plain
|
||||
# grdctl --system, not a manual pkexec invocation.
|
||||
self.assertIn('grdctl --system "$@"', self.setup_service)
|
||||
self.assertNotIn("runuser", self.setup_service)
|
||||
self.assertNotIn("sudo", self.setup_service)
|
||||
# No direct Nix-store pkexec invocation (pkgs.polkit}/bin/pkexec).
|
||||
self.assertNotIn("pkgs.polkit}/bin/pkexec", self.setup_service)
|
||||
|
||||
def test_privilege_escalation_packages_absent_from_setup_path(self):
|
||||
self.assertNotIn("pkgs.polkit", self.setup_service)
|
||||
self.assertNotIn("pkgs.util-linux", self.setup_service)
|
||||
|
||||
def test_run_wrappers_bin_prepended_to_path(self):
|
||||
# /run/wrappers/bin must be prepended to PATH before any grdctl_system
|
||||
# invocation so that grdctl --system resolves the NixOS setuid pkexec.
|
||||
path_export = 'export PATH="/run/wrappers/bin:$PATH"'
|
||||
grdctl_marker = "grdctl_system"
|
||||
script = self.setup_service
|
||||
path_idx = script.find(path_export)
|
||||
grdctl_idx = script.find(grdctl_marker)
|
||||
self.assertGreater(path_idx, -1, f"{path_export!r} not found in setup script")
|
||||
self.assertGreater(
|
||||
grdctl_idx, path_idx,
|
||||
"PATH export must appear before the first grdctl_system usage",
|
||||
)
|
||||
|
||||
def test_pkexec_preflight_check(self):
|
||||
# A preflight must confirm /run/wrappers/bin/pkexec is executable
|
||||
# with a clear error message before any GRD configuration changes.
|
||||
self.assertIn("test -x /run/wrappers/bin/pkexec", self.setup_service)
|
||||
self.assertIn(
|
||||
"/run/wrappers/bin/pkexec is absent or not executable",
|
||||
self.setup_service,
|
||||
)
|
||||
|
||||
def test_hub_files_are_the_source_of_truth_for_username_and_password(self):
|
||||
self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service)
|
||||
self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service)
|
||||
self.assertIn(USERNAME_READ, self.setup_service)
|
||||
self.assertIn(USERNAME_LENGTH_GUARD, self.setup_service)
|
||||
self.assertIn('case "$USERNAME" in', self.setup_service)
|
||||
self.assertIn('[A-Za-z_][A-Za-z0-9_-]*)', self.setup_service)
|
||||
self.assertIn("RDP username is too long (''${#USERNAME} characters, maximum 32)", self.setup_service)
|
||||
self.assertIn("RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens", self.setup_service)
|
||||
self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service)
|
||||
self.assertIn("tr -d '\\n'", self.setup_service)
|
||||
self.assertIn('"$PASSWORD_FILE"', self.setup_service)
|
||||
self.assertIn(SHORT_PASSWORD_GUARD, self.setup_service)
|
||||
self.assertIn("RDP password is too short (''${#PASSWORD} characters, minimum 8)", self.setup_service)
|
||||
self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service)
|
||||
self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service)
|
||||
|
||||
def test_secure_permissions_are_enforced_for_state_and_secret_files(self):
|
||||
self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source)
|
||||
self.assertIn("chmod 700", self.setup_service)
|
||||
self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service)
|
||||
self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service)
|
||||
self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service)
|
||||
self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service)
|
||||
self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service)
|
||||
self.assertIn('LOCAL_IP="$(hostname -I | awk \'{print $1}\')"', self.setup_service)
|
||||
self.assertIn('LOCAL_IP="127.0.0.1"', self.setup_service)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,171 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import mock_open, patch
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def _install_web_stubs():
|
||||
if "fastapi" in sys.modules:
|
||||
return
|
||||
|
||||
class _HTTPException(Exception):
|
||||
def __init__(self, status_code=None, detail=None):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
class _FastAPI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def mount(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def add_middleware(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def __getattr__(self, _name):
|
||||
def _decorator_factory(*args, **kwargs):
|
||||
def _decorator(func):
|
||||
return func
|
||||
|
||||
return _decorator
|
||||
|
||||
return _decorator_factory
|
||||
|
||||
class _BaseModel:
|
||||
pass
|
||||
|
||||
class _StaticFiles:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _Jinja2Templates:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _BaseHTTPMiddleware:
|
||||
pass
|
||||
|
||||
fastapi_module = types.ModuleType("fastapi")
|
||||
fastapi_module.FastAPI = _FastAPI
|
||||
fastapi_module.HTTPException = _HTTPException
|
||||
sys.modules["fastapi"] = fastapi_module
|
||||
|
||||
responses_module = types.ModuleType("fastapi.responses")
|
||||
responses_module.HTMLResponse = object
|
||||
responses_module.JSONResponse = object
|
||||
responses_module.RedirectResponse = object
|
||||
sys.modules["fastapi.responses"] = responses_module
|
||||
|
||||
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||
staticfiles_module.StaticFiles = _StaticFiles
|
||||
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||
|
||||
templating_module = types.ModuleType("fastapi.templating")
|
||||
templating_module.Jinja2Templates = _Jinja2Templates
|
||||
sys.modules["fastapi.templating"] = templating_module
|
||||
|
||||
requests_module = types.ModuleType("fastapi.requests")
|
||||
requests_module.Request = object
|
||||
sys.modules["fastapi.requests"] = requests_module
|
||||
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
pydantic_module.BaseModel = _BaseModel
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||
|
||||
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||
starlette_middleware_module.base = starlette_base_module
|
||||
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||
|
||||
starlette_module = types.ModuleType("starlette")
|
||||
starlette_module.middleware = starlette_middleware_module
|
||||
sys.modules["starlette"] = starlette_module
|
||||
|
||||
|
||||
_install_web_stubs()
|
||||
from sovran_systemsos_web import server
|
||||
|
||||
|
||||
class ServiceDetailRouterWordingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_livekit_service_detail_includes_internal_ip(self):
|
||||
service_cfg = {
|
||||
"services": [
|
||||
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
|
||||
]
|
||||
}
|
||||
domain_eval = {
|
||||
"domain_status": {"status": "ok"},
|
||||
"domain_reachable": {"reachable": True},
|
||||
"domain_check_steps": [],
|
||||
"has_issues": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(server, "load_config", return_value=service_cfg),
|
||||
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
|
||||
patch.object(server.sysctl, "is_active", return_value="active"),
|
||||
patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False),
|
||||
patch.dict(
|
||||
server.SERVICE_PORT_REQUIREMENTS,
|
||||
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
|
||||
clear=False,
|
||||
),
|
||||
patch("builtins.open", mock_open(read_data="call.example.com\n")),
|
||||
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
|
||||
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
|
||||
patch.object(server, "_save_internal_ip"),
|
||||
patch.object(server, "_get_listening_ports", return_value={"tcp": {7881}, "udp": set()}),
|
||||
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
|
||||
):
|
||||
result = await server.api_service_detail("livekit.service")
|
||||
|
||||
self.assertEqual(result["internal_ip"], "192.168.1.44")
|
||||
self.assertEqual(result["extra_ports"][0]["status"], "listening")
|
||||
self.assertEqual(result["domain_check_steps"][-1]["label"], "Router Setup Needed")
|
||||
|
||||
async def test_livekit_router_step_uses_not_ready_yet_wording(self):
|
||||
service_cfg = {
|
||||
"services": [
|
||||
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
|
||||
]
|
||||
}
|
||||
domain_eval = {
|
||||
"domain_status": {"status": "ok"},
|
||||
"domain_reachable": {"reachable": True},
|
||||
"domain_check_steps": [],
|
||||
"has_issues": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(server, "load_config", return_value=service_cfg),
|
||||
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
|
||||
patch.object(server.sysctl, "is_active", return_value="active"),
|
||||
patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False),
|
||||
patch.dict(
|
||||
server.SERVICE_PORT_REQUIREMENTS,
|
||||
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
|
||||
clear=False,
|
||||
),
|
||||
patch("builtins.open", mock_open(read_data="call.example.com\n")),
|
||||
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
|
||||
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
|
||||
patch.object(server, "_save_internal_ip"),
|
||||
patch.object(server, "_get_listening_ports", return_value={"tcp": set(), "udp": set()}),
|
||||
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
|
||||
):
|
||||
result = await server.api_service_detail("livekit.service")
|
||||
|
||||
self.assertEqual(result["extra_ports"][0]["status"], "closed")
|
||||
self.assertIn("Not ready yet", result["domain_check_steps"][-1]["detail"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style.
|
||||
|
||||
Prior to this fix, the three HTML routes called:
|
||||
templates.TemplateResponse("name.html", {"request": request, ...})
|
||||
which passes the context dict as the second positional argument. With the
|
||||
updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0,
|
||||
FastAPI 0.136.3) that positional argument is the template name, causing Jinja2
|
||||
to receive a dict as a cache key and raise:
|
||||
TypeError: unhashable type: 'dict'
|
||||
|
||||
The fix updates every call to use keyword arguments:
|
||||
templates.TemplateResponse(request=request, name="name.html", context={...})
|
||||
"""
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py"
|
||||
|
||||
|
||||
def _template_response_calls(source: str):
|
||||
"""Return a list of ast.Call nodes that are TemplateResponse calls."""
|
||||
tree = ast.parse(source)
|
||||
calls = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse":
|
||||
calls.append(node)
|
||||
return calls
|
||||
|
||||
|
||||
class TemplateResponseSignatureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = SERVER_PY.read_text()
|
||||
self.calls = _template_response_calls(self.source)
|
||||
|
||||
def test_at_least_one_template_response_call_found(self):
|
||||
self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py")
|
||||
|
||||
def test_no_old_style_positional_dict_context(self):
|
||||
"""No TemplateResponse call should pass a dict literal as its second positional arg.
|
||||
|
||||
The old style was:
|
||||
templates.TemplateResponse("name.html", {"request": request, ...})
|
||||
where args[0] is a string and args[1] is a Dict node. That pattern
|
||||
triggers the Starlette 1.1.0 bug.
|
||||
"""
|
||||
for call in self.calls:
|
||||
positional = call.args
|
||||
if len(positional) >= 2 and isinstance(positional[1], ast.Dict):
|
||||
self.fail(
|
||||
f"Found old-style TemplateResponse call at line {call.lineno}: "
|
||||
"second positional argument is a dict literal. "
|
||||
"Use keyword arguments (request=, name=, context=) instead."
|
||||
)
|
||||
|
||||
def test_request_not_duplicated_in_context(self):
|
||||
"""The 'request' key must not appear inside the context= dict when
|
||||
request= is already passed as a dedicated keyword argument."""
|
||||
for call in self.calls:
|
||||
kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)}
|
||||
|
||||
if "request" not in kw_dict:
|
||||
continue # no request= kwarg, nothing to check
|
||||
|
||||
context_node = kw_dict.get("context")
|
||||
if not isinstance(context_node, ast.Dict):
|
||||
continue
|
||||
|
||||
for key_node in context_node.keys:
|
||||
if isinstance(key_node, ast.Constant) and key_node.value == "request":
|
||||
self.fail(
|
||||
f"TemplateResponse at line {call.lineno} passes 'request' both as "
|
||||
"request= keyword argument and inside the context dict."
|
||||
)
|
||||
|
||||
def test_all_calls_use_keyword_arguments(self):
|
||||
"""Every TemplateResponse call should use keyword arguments for request, name,
|
||||
and context rather than relying on positional ordering."""
|
||||
for call in self.calls:
|
||||
kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)}
|
||||
self.assertIn(
|
||||
"request",
|
||||
kw_args,
|
||||
f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.",
|
||||
)
|
||||
self.assertIn(
|
||||
"name",
|
||||
kw_args,
|
||||
f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
After Width: | Height: | Size: 442 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#153126"/>
|
||||
<stop offset="55%" stop-color="#0F241B"/>
|
||||
<stop offset="100%" stop-color="#091C14"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#42F39A"/>
|
||||
<stop offset="45%" stop-color="#28D978"/>
|
||||
<stop offset="100%" stop-color="#1AA45D"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#27C86F"/>
|
||||
<stop offset="100%" stop-color="#157E49"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 .18 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
|
||||
fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
|
||||
fill="none" filter="url(#innerShade)"/>
|
||||
|
||||
<path d="M128 32 A96 96 0 1 1 58 196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<path d="M128 56 A72 72 0 1 1 76 178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
|
||||
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,300 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 3440 1440"
|
||||
width="3440"
|
||||
height="1440"
|
||||
version="1.1"
|
||||
id="svg21"
|
||||
sodipodi:docname="sovran-wallpaper-12-ultrawide-3440x1440.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="sovran-wallpaper-12-ultrawide-3440x1440.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview21"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.657762"
|
||||
inkscape:cx="1721.7474"
|
||||
inkscape:cy="718.34493"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1363"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg21" /><defs
|
||||
id="defs14"><linearGradient
|
||||
id="bg"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="1"><stop
|
||||
offset="0%"
|
||||
stop-color="#040706"
|
||||
id="stop1" /><stop
|
||||
offset="50%"
|
||||
stop-color="#06100c"
|
||||
id="stop2" /><stop
|
||||
offset="100%"
|
||||
stop-color="#050706"
|
||||
id="stop3" /></linearGradient><radialGradient
|
||||
id="softGlow"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="210"
|
||||
fx="0"
|
||||
fy="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(210)"><stop
|
||||
offset="0%"
|
||||
stop-color="#28d978"
|
||||
stop-opacity="0.04"
|
||||
id="stop4" /><stop
|
||||
offset="100%"
|
||||
stop-color="#28d978"
|
||||
stop-opacity="0"
|
||||
id="stop5" /></radialGradient><linearGradient
|
||||
id="tileBg"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="340"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(210)"><stop
|
||||
offset="0%"
|
||||
stop-color="#153126"
|
||||
id="stop6" /><stop
|
||||
offset="55%"
|
||||
stop-color="#0F241B"
|
||||
id="stop7" /><stop
|
||||
offset="100%"
|
||||
stop-color="#091C14"
|
||||
id="stop8" /></linearGradient><linearGradient
|
||||
id="outerArc"
|
||||
x1="75.634857"
|
||||
y1="47.268153"
|
||||
x2="326.94922"
|
||||
y2="298.58251"
|
||||
gradientTransform="matrix(0.95194204,0,0,1.0504841,210,0)"
|
||||
gradientUnits="userSpaceOnUse"><stop
|
||||
offset="0%"
|
||||
stop-color="#42F39A"
|
||||
id="stop9" /><stop
|
||||
offset="45%"
|
||||
stop-color="#28D978"
|
||||
id="stop10" /><stop
|
||||
offset="100%"
|
||||
stop-color="#1AA45D"
|
||||
id="stop11" /></linearGradient><linearGradient
|
||||
id="innerArc"
|
||||
x1="130.64136"
|
||||
y1="106.15404"
|
||||
x2="258.09194"
|
||||
y2="251.81184"
|
||||
gradientTransform="matrix(0.95325178,0,0,1.0490408,210,0)"
|
||||
gradientUnits="userSpaceOnUse"><stop
|
||||
offset="0%"
|
||||
stop-color="#27C86F"
|
||||
id="stop12" /><stop
|
||||
offset="100%"
|
||||
stop-color="#157E49"
|
||||
id="stop13" /></linearGradient><filter
|
||||
id="tileShadow"
|
||||
x="-0.12705882"
|
||||
y="-0.12705882"
|
||||
width="1.2541176"
|
||||
height="1.2952941"><feOffset
|
||||
dy="14"
|
||||
id="feOffset13" /><feGaussianBlur
|
||||
stdDeviation="18"
|
||||
result="blur"
|
||||
id="feGaussianBlur13" /><feColorMatrix
|
||||
type="matrix"
|
||||
values=" 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .24 0"
|
||||
id="feColorMatrix13" /><feMerge
|
||||
id="feMerge14"><feMergeNode
|
||||
in="blur"
|
||||
id="feMergeNode13" /><feMergeNode
|
||||
in="SourceGraphic"
|
||||
id="feMergeNode14" /></feMerge></filter><linearGradient
|
||||
id="bg-7"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
|
||||
offset="0%"
|
||||
stop-color="#153126"
|
||||
id="stop1-0" /><stop
|
||||
offset="55%"
|
||||
stop-color="#0F241B"
|
||||
id="stop2-9" /><stop
|
||||
offset="100%"
|
||||
stop-color="#091C14"
|
||||
id="stop3-3" /></linearGradient><linearGradient
|
||||
id="outerArc-6"
|
||||
x1="70"
|
||||
y1="40"
|
||||
x2="190"
|
||||
y2="210"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
|
||||
offset="0%"
|
||||
stop-color="#42F39A"
|
||||
id="stop4-0" /><stop
|
||||
offset="45%"
|
||||
stop-color="#28D978"
|
||||
id="stop5-6" /><stop
|
||||
offset="100%"
|
||||
stop-color="#1AA45D"
|
||||
id="stop6-2" /></linearGradient><linearGradient
|
||||
id="innerArc-6"
|
||||
x1="90"
|
||||
y1="60"
|
||||
x2="180"
|
||||
y2="190"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
|
||||
offset="0%"
|
||||
stop-color="#27C86F"
|
||||
id="stop7-1" /><stop
|
||||
offset="100%"
|
||||
stop-color="#157E49"
|
||||
id="stop8-8" /></linearGradient><filter
|
||||
id="innerShade"
|
||||
x="-0.049180328"
|
||||
y="-0.049180328"
|
||||
width="1.0983607"
|
||||
height="1.1065574"><feOffset
|
||||
dx="0"
|
||||
dy="2"
|
||||
id="feOffset8" /><feGaussianBlur
|
||||
stdDeviation="5"
|
||||
result="blur"
|
||||
id="feGaussianBlur8" /><feComposite
|
||||
in="blur"
|
||||
in2="SourceAlpha"
|
||||
operator="arithmetic"
|
||||
k2="-1"
|
||||
k3="1"
|
||||
id="feComposite8" /><feColorMatrix
|
||||
type="matrix"
|
||||
values=" 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0"
|
||||
id="feColorMatrix8" /></filter></defs><rect
|
||||
width="3440"
|
||||
height="1440"
|
||||
fill="url(#bg)"
|
||||
id="rect14" /><!-- centered for ultrawide balance --><g
|
||||
id="g3"
|
||||
transform="translate(18.243681,41.048282)"><g
|
||||
id="g2"
|
||||
transform="translate(-161.15251,-21.284294)"><g
|
||||
transform="translate(1330,720)"
|
||||
id="g20"><circle
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="310"
|
||||
fill="none"
|
||||
stroke="rgba(242,255,247,0.045)"
|
||||
stroke-width="1"
|
||||
id="circle15" /><circle
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="390"
|
||||
fill="none"
|
||||
stroke="rgba(66,243,154,0.055)"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="3, 22"
|
||||
id="circle16" /></g><g
|
||||
transform="translate(1565,702)"
|
||||
id="g21"><text
|
||||
x="0"
|
||||
y="0"
|
||||
fill="#c3cbc6"
|
||||
font-family="Inter, ui-sans-serif, system-ui, '-apple-system', BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
font-size="42px"
|
||||
font-weight="500"
|
||||
letter-spacing="8"
|
||||
id="text20">PRIVACY. SOVEREIGNTY. BITCOIN.</text><rect
|
||||
x="0"
|
||||
y="56"
|
||||
width="240"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="#42f39a"
|
||||
id="rect21" /></g><g
|
||||
id="g1"
|
||||
transform="matrix(1.2581949,0,0,1.2194235,1167.4138,564.08337)"><rect
|
||||
width="256"
|
||||
height="256"
|
||||
rx="48"
|
||||
ry="48"
|
||||
fill="url(#bg)"
|
||||
id="rect8"
|
||||
style="fill:url(#bg-7)"
|
||||
x="0"
|
||||
y="0" /><rect
|
||||
x="1.5"
|
||||
y="1.5"
|
||||
width="253"
|
||||
height="253"
|
||||
rx="46.5"
|
||||
ry="46.5"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
id="rect9" /><rect
|
||||
x="6"
|
||||
y="6"
|
||||
width="244"
|
||||
height="244"
|
||||
rx="42"
|
||||
ry="42"
|
||||
fill="none"
|
||||
filter="url(#innerShade)"
|
||||
id="rect10" /><path
|
||||
d="M 128,32 A 96,96 0 1 1 58,196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
id="path10"
|
||||
style="stroke:url(#outerArc-6)" /><path
|
||||
d="M 128,56 A 72,72 0 1 1 76,178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"
|
||||
id="path11"
|
||||
style="stroke:url(#innerArc-6)" /><circle
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="8"
|
||||
fill="#f2fff7"
|
||||
id="circle11" /><circle
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="18"
|
||||
fill="none"
|
||||
stroke="#7bffc0"
|
||||
stroke-opacity="0.14"
|
||||
stroke-width="4"
|
||||
id="circle12" /></g><rect
|
||||
x="0"
|
||||
y="56"
|
||||
width="560"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="rgba(242,255,247,0.08)"
|
||||
id="rect20"
|
||||
style="fill:#b3b3b3"
|
||||
transform="translate(1565,702)" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -3,7 +3,6 @@
|
||||
{
|
||||
imports = [
|
||||
./modules/modules.nix
|
||||
./iso/branding.nix
|
||||
];
|
||||
|
||||
# ── Boot ────────────────────────────────────────────────────
|
||||
@@ -11,6 +10,8 @@
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
boot.loader.efi.efiSysMountPoint = "/boot/efi";
|
||||
boot.kernelPackages = pkgs.linuxPackages_latest;
|
||||
boot.kernelParams = [ "quiet" "loglevel=3" "rd.systemd.show_status=false" "udev.log_level=3" ];
|
||||
boot.blacklistedKernelModules = [ "rxrpc" ];
|
||||
|
||||
# ── Filesystems ─────────────────────────────────────────────
|
||||
fileSystems."/run/media/Second_Drive" = {
|
||||
@@ -25,21 +26,22 @@
|
||||
nix.settings = {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
download-buffer-size = 524288000;
|
||||
|
||||
# Network resilience for cache.nixos.org (Fastly) flakiness.
|
||||
connect-timeout = 10; # fail-fast on dead TCP connects (default: 0 = unlimited)
|
||||
stalled-download-timeout = 90; # default 300s; retry sooner on stalled transfers
|
||||
download-attempts = 7; # default 5
|
||||
http-connections = 25; # cap concurrency (helps MTU/middlebox paths)
|
||||
fallback = true; # build locally if a substitute can't be fetched
|
||||
};
|
||||
|
||||
# ── Networking ──────────────────────────────────────────────
|
||||
# NOTE: hostName must remain "nixos" to match the nixosConfigurations key in
|
||||
# flake.nix. Changing it breaks flake-based remote upgrades with:
|
||||
# error: does not provide attribute 'nixosConfigurations."<hostname>"'
|
||||
networking.hostName = "nixos";
|
||||
networking.networkmanager.enable = true;
|
||||
networking.firewall.enable = true;
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ];
|
||||
networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ];
|
||||
networking.firewall.allowedUDPPorts = [ 5353 ];
|
||||
|
||||
# ── Avahi (mDNS) ───────────────────────────────────────────
|
||||
# Advertise as sovransystemsos.local on the LAN without changing the system
|
||||
# hostname (which must remain "nixos" for flake compatibility — see above).
|
||||
services.avahi = {
|
||||
enable = true;
|
||||
hostName = "sovransystemsos";
|
||||
@@ -48,17 +50,42 @@
|
||||
};
|
||||
|
||||
# ── Locale / Time ──────────────────────────────────────────
|
||||
time.timeZone = "America/Los_Angeles";
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
time.timeZone = null;
|
||||
i18n.defaultLocale = lib.mkDefault "en_US.UTF-8";
|
||||
i18n.supportedLocales = [
|
||||
"en_US.UTF-8/UTF-8"
|
||||
"en_GB.UTF-8/UTF-8"
|
||||
"es_ES.UTF-8/UTF-8"
|
||||
"fr_FR.UTF-8/UTF-8"
|
||||
"de_DE.UTF-8/UTF-8"
|
||||
"pt_BR.UTF-8/UTF-8"
|
||||
"ja_JP.UTF-8/UTF-8"
|
||||
"zh_CN.UTF-8/UTF-8"
|
||||
"ko_KR.UTF-8/UTF-8"
|
||||
"ru_RU.UTF-8/UTF-8"
|
||||
"ar_SA.UTF-8/UTF-8"
|
||||
"hi_IN/UTF-8"
|
||||
];
|
||||
|
||||
# ── Desktop ────────────────────────────────────────────────
|
||||
services.displayManager.gdm.enable = true;
|
||||
services.displayManager.gdm.autoSuspend = false;
|
||||
services.displayManager.gdm.wayland = true;
|
||||
services.desktopManager.gnome.enable = true;
|
||||
services.printing.enable = true;
|
||||
systemd.enableEmergencyMode = false;
|
||||
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
|
||||
security.pam.services.gdm-password.enableGnomeKeyring = true;
|
||||
security.pam.services.gdm-autologin.enableGnomeKeyring = true;
|
||||
|
||||
# Declaratively guarantee the GNOME Keyring default pointer exists.
|
||||
# Defining the full path ensures root doesn't accidentally lock the user out of .local
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /home/free/.local 0700 free users -"
|
||||
"d /home/free/.local/share 0700 free users -"
|
||||
"d /home/free/.local/share/keyrings 0700 free users -"
|
||||
"f /home/free/.local/share/keyrings/default 0600 free users - login\n"
|
||||
];
|
||||
|
||||
|
||||
# ── Audio ──────────────────────────────────────────────────
|
||||
services.pulseaudio.enable = false;
|
||||
@@ -77,16 +104,25 @@
|
||||
extraGroups = [ "networkmanager" ];
|
||||
};
|
||||
|
||||
services.displayManager.autoLogin.enable = true;
|
||||
services.displayManager.autoLogin.user = "free";
|
||||
services.displayManager.autoLogin.enable = false;
|
||||
|
||||
# ── Flatpak ────────────────────────────────────────────────
|
||||
services.flatpak.enable = true;
|
||||
systemd.services.flatpak-repo = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" "nss-lookup.target" ];
|
||||
wants = [ "network-online.target" "nss-lookup.target" ];
|
||||
path = [ pkgs.flatpak ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "15s";
|
||||
};
|
||||
unitConfig = {
|
||||
StartLimitIntervalSec = 120;
|
||||
StartLimitBurst = 5;
|
||||
};
|
||||
script = ''
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
'';
|
||||
@@ -94,9 +130,9 @@
|
||||
|
||||
# ── Packages ───────────────────────────────────────────────
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
nftables
|
||||
git wget fish htop btop
|
||||
gnomeExtensions.transparent-top-bar-adjustable-transparency
|
||||
gnomeExtensions.dash-to-dock
|
||||
@@ -109,12 +145,12 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave dua bitwarden-desktop
|
||||
synadm brave dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox element-desktop wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||
matrix-synapse age
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
];
|
||||
|
||||
# ── Shell ──────────────────────────────────────────────────
|
||||
@@ -160,7 +196,6 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
enable = true;
|
||||
systemCronJobs = [
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/external_ip/external_ip.sh"
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -22,4 +22,20 @@
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
# ─── Custom Caddy virtual hosts ──────────────────────────
|
||||
# Uncomment and edit below to add your own Caddy sites:
|
||||
#
|
||||
# sovran_systemsOS.caddy.extraVirtualHosts = ''
|
||||
# mysite.example.com {
|
||||
# encode gzip zstd
|
||||
# root * /var/lib/www/mysite
|
||||
# php_fastcgi unix//run/phpfpm/mypool.sock
|
||||
# file_server browse
|
||||
# }
|
||||
#
|
||||
# anotherdomain.com {
|
||||
# reverse_proxy localhost:9090
|
||||
# }
|
||||
# '';
|
||||
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# Sovran Hub — Manual Backup
|
||||
|
||||
The manual backup service copies critical system data from your Sovran Pro to an external USB drive, providing a third copy of your data (your Sovran Pro already maintains an automatic internal backup on its second drive).
|
||||
|
||||
Backups are written to:
|
||||
|
||||
```
|
||||
<USB drive>/Sovran_SystemsOS_Backup/<timestamp>/
|
||||
```
|
||||
|
||||
where `<timestamp>` is formatted as `YYYYMMDD_HHMMSS`.
|
||||
|
||||
---
|
||||
|
||||
## Backup Stages
|
||||
|
||||
The script always attempts all four stages, but skips stages that are irrelevant to the system's configured role (see [Per-Role Breakdown](#per-role-breakdown) below).
|
||||
|
||||
| Stage | Directory | Contents |
|
||||
|-------|-----------|----------|
|
||||
| **1/4 — NixOS config** | `/etc/nixos/` | Full NixOS system configuration: `role-state.nix`, `custom.nix`, flake files, and any other config managed by the Hub |
|
||||
| **2/4 — Secrets** | `/etc/nix-bitcoin-secrets`, `/var/lib/domains`, `/var/lib/secrets` | Bitcoin/LND secrets, domain configurations for all web services, and Hub state files |
|
||||
| **3/4 — Home directory** | `/home/` | All user home directories (`.cache/` and Trash are excluded) |
|
||||
| **4/4 — LND wallet data** | `/var/lib/lnd/` | Lightning Network node wallet and channel data (log files excluded) |
|
||||
|
||||
---
|
||||
|
||||
## Per-Role Breakdown
|
||||
|
||||
The script detects the system role at runtime by reading `/var/lib/sovran-hub/config.json` (falling back to `/etc/nixos/role-state.nix`) and adjusts its behaviour accordingly.
|
||||
|
||||
### Server + Desktop (default)
|
||||
|
||||
All services are enabled: Bitcoin, Matrix Synapse, Vaultwarden, WordPress, Nextcloud.
|
||||
|
||||
| Stage | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Stage 1 — NixOS config | ✅ Backed up | Full server configuration |
|
||||
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets, domain configs, and Hub state |
|
||||
| Stage 3 — Home directory | ✅ Backed up | Desktop user data |
|
||||
| Stage 4 — LND wallet | ✅ Backed up | Lightning wallet and channel data |
|
||||
|
||||
This produces the largest backup. All four stages generate meaningful data.
|
||||
|
||||
### Desktop Only
|
||||
|
||||
All server services are disabled (`bitcoin = false`, `synapse = false`, `vaultwarden = false`, `wordpress = false`, `nextcloud = false`). Only GNOME desktop is active.
|
||||
|
||||
| Stage | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Stage 1 — NixOS config | ✅ Backed up | Simpler config (no server services) |
|
||||
| Stage 2 — Secrets | ⚠️ Partial | `/etc/nix-bitcoin-secrets` is **skipped** (not applicable for Desktop Only role). `/var/lib/domains` and `/var/lib/secrets` (Hub state) are still backed up if present |
|
||||
| Stage 3 — Home directory | ✅ Backed up | **The most important data for this role** |
|
||||
| Stage 4 — LND wallet | ⏭️ Skipped | Explicitly skipped — not applicable for Desktop Only role |
|
||||
|
||||
This produces the smallest and fastest backup. Stages 1 and 3 are the primary sources of meaningful data.
|
||||
|
||||
### Node (Bitcoin-only)
|
||||
|
||||
Only the Bitcoin ecosystem is active: `bitcoind`, `electrs`, `lnd`, `rtl`, `btcpay`, `mempool`, and `bip110`. All other server services are disabled.
|
||||
|
||||
| Stage | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Stage 1 — NixOS config | ✅ Backed up | Node-specific configuration |
|
||||
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets and Hub state. `/var/lib/domains` may be minimal (BTCPay runs but is not exposed via Caddy) |
|
||||
| Stage 3 — Home directory | ✅ Backed up | User data |
|
||||
| Stage 4 — LND wallet | ✅ Backed up | **Critical** — Lightning wallet and channel data |
|
||||
|
||||
All four stages run, matching Server + Desktop behaviour. The `/var/lib/domains` directory may be sparsely populated since non-Bitcoin web services are not configured.
|
||||
|
||||
---
|
||||
|
||||
## Backup Manifest
|
||||
|
||||
After all stages complete, the script writes a `BACKUP_MANIFEST.txt` file inside the timestamped backup directory. This file records the date, hostname, detected role, target drive, and a directory listing of everything that was backed up.
|
||||
|
||||
---
|
||||
|
||||
## Running the Backup
|
||||
|
||||
The backup is triggered from the Sovran Hub web UI. You can also run it directly:
|
||||
|
||||
```bash
|
||||
# Auto-detect the first external USB drive
|
||||
sudo bash /path/to/sovran-hub-backup.sh
|
||||
|
||||
# Specify a target drive explicitly
|
||||
sudo BACKUP_TARGET=/run/media/<user>/<drive> bash /path/to/sovran-hub-backup.sh
|
||||
```
|
||||
|
||||
The script requires at least **10 GB** of free space on the target drive and will refuse to write to internal system drives.
|
||||
|
||||
Logs are written to `/var/log/sovran-hub-backup.log` and the current status (`RUNNING`, `SUCCESS`, or `FAILED`) is tracked in `/var/log/sovran-hub-backup.status`.
|
||||
@@ -1,259 +0,0 @@
|
||||
# Tech Support: Security Design, User Flow, and Incident Response
|
||||
|
||||
## Overview
|
||||
|
||||
The Sovran Hub includes a **Tech Support** feature that lets Sovran Systems
|
||||
staff remotely diagnose and fix issues on a user's machine via SSH — without
|
||||
ever having access to private keys or wallet funds.
|
||||
|
||||
Wallet protection is the default. The user must make an active, time-limited
|
||||
choice to grant support staff access to wallet files, and can revoke that
|
||||
access at any time.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Restricted User Instead of Root
|
||||
|
||||
When a user enables support access the Hub:
|
||||
|
||||
1. Ensures the `sovran-support` system user exists (declared declaratively in
|
||||
`modules/core/tech-support.nix`; the Hub also provisions it on demand as a
|
||||
fallback on non-NixOS systems).
|
||||
2. Writes the Sovran Systems public SSH key **only** to
|
||||
`/var/lib/sovran-support/.ssh/authorized_keys`, not to root's
|
||||
`authorized_keys`.
|
||||
3. Applies POSIX ACLs (`setfacl -R -m u:sovran-support:---`) to every wallet
|
||||
directory that exists on disk, denying all access by the support user.
|
||||
4. Records a timestamped `SUPPORT_ENABLED` event in the audit log at
|
||||
`/var/log/sovran-support-audit.log`.
|
||||
|
||||
When the session ends (or if the Hub cannot create the restricted user), the
|
||||
key is removed and all ACLs are revoked immediately.
|
||||
|
||||
### Protected Wallet Paths
|
||||
|
||||
The following directories are locked by default when a support session starts:
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets |
|
||||
| `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet |
|
||||
| `/var/lib/lnd` | LND wallet and channel database |
|
||||
| `/home` | User home directories |
|
||||
|
||||
Paths are only locked if they exist on disk at the time the session starts.
|
||||
|
||||
### POSIX ACL Mechanics
|
||||
|
||||
POSIX ACLs on Linux handle access checks in this order:
|
||||
|
||||
1. If the process UID matches the file owner UID → use owner permissions
|
||||
2. **If there is a matching named-user ACL entry → use that entry's
|
||||
permissions** (clamped by the mask entry)
|
||||
3. If any group matches → use group permissions
|
||||
4. Otherwise → use "other" permissions
|
||||
|
||||
Setting `u:sovran-support:---` creates a named-user ACL entry with no
|
||||
permissions. Because the named-user entry is checked before the group/other
|
||||
entries, the support user cannot access those directories regardless of the
|
||||
"other" permission bits.
|
||||
|
||||
`setfacl` and `getfacl` are provided by the `acl` package, which is added to
|
||||
`environment.systemPackages` by `modules/core/tech-support.nix`.
|
||||
|
||||
### Fallback to Root (When Restricted User Cannot Be Created)
|
||||
|
||||
If the `sovran-support` user does not exist and cannot be created (e.g.,
|
||||
`users.mutableUsers = false` and the declarative module has not been deployed
|
||||
yet), the Hub falls back to adding the support key to root's
|
||||
`authorized_keys`. The modal prominently warns the user when this has happened
|
||||
so they can decide whether to end the session.
|
||||
|
||||
### Audit Log
|
||||
|
||||
Every session event is appended to `/var/log/sovran-support-audit.log`:
|
||||
|
||||
```
|
||||
[2025-01-15 14:32:01 UTC] SUPPORT_ENABLED: restricted_user=True acl_applied=True protected_paths=4
|
||||
[2025-01-15 14:45:00 UTC] WALLET_UNLOCKED: duration=3600s expires=2025-01-15 15:45:00 UTC
|
||||
[2025-01-15 15:45:00 UTC] WALLET_RELOCKED: auto-expired
|
||||
[2025-01-15 16:01:22 UTC] SUPPORT_DISABLED
|
||||
```
|
||||
|
||||
The last 100 lines of this log are accessible from the Hub UI while a session
|
||||
is active (or after it ends, until the page is refreshed).
|
||||
|
||||
---
|
||||
|
||||
## Security Tradeoffs
|
||||
|
||||
### What This Protects Against
|
||||
|
||||
- **Accidental wallet exposure** — support staff cannot read wallet files
|
||||
during a normal session; they must ask the user to explicitly grant access.
|
||||
- **Credential theft** — private keys in the wallet directories are not
|
||||
visible to the `sovran-support` user by default.
|
||||
- **Scope creep** — the restricted user account limits the blast radius of an
|
||||
SSH session compared to direct root access.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
| Limitation | Mitigation |
|
||||
|------------|------------|
|
||||
| Support user still has system-wide bash access | Restrict with `ForceCommand` or AppArmor in the NixOS config if a narrower scope is required |
|
||||
| ACLs apply only to directories that exist at session start | If new wallet directories are created during a session, they are not auto-protected. Re-lock and re-enable support to pick up new paths |
|
||||
| Root fallback grants full access | The Hub UI warns the user prominently; users should end the session if they are uncomfortable |
|
||||
| `setfacl` / ACL filesystem support required | The `acl` package is declared in `tech-support.nix`; most Linux filesystems (ext4, btrfs, xfs) support ACLs by default |
|
||||
| Wallet access grant is time-limited but lazy-expired | Expiry is checked on the next `/api/support/status` poll (every 10 seconds in the UI); there is a small window after expiry |
|
||||
|
||||
### Defense-in-Depth Recommendations
|
||||
|
||||
For environments that require stronger isolation, consider layering one or
|
||||
more additional controls:
|
||||
|
||||
- **`ForceCommand`** in `sshd_config` (or `~/.ssh/authorized_keys` command
|
||||
prefix) to restrict the support user to a specific diagnostic script.
|
||||
- **`ChrootDirectory`** in the `sshd_config` `Match User sovran-support` block
|
||||
to confine the session to a prepared directory tree.
|
||||
- **AppArmor or SELinux** profiles that deny the support process read access
|
||||
to wallet paths at the kernel level.
|
||||
- **Namespace/bind-mount overlays** (e.g., via a wrapper systemd unit) to
|
||||
present a sanitized filesystem view.
|
||||
|
||||
---
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
User opens Hub → Clicks "Tech Support" in sidebar
|
||||
│
|
||||
▼
|
||||
Modal: "Need help from Sovran Systems?"
|
||||
• Explains what will happen
|
||||
• Shows Wallet Protection notice
|
||||
• User clicks "Enable Support Access"
|
||||
│
|
||||
▼
|
||||
Hub: 1. Creates / verifies sovran-support user
|
||||
2. Writes SSH key to that user's authorized_keys
|
||||
3. Applies POSIX ACL deny on all existing wallet paths
|
||||
4. Saves session metadata + writes SUPPORT_ENABLED to audit log
|
||||
│
|
||||
▼
|
||||
Modal: "Support Access is Active"
|
||||
• Live session duration timer
|
||||
• Wallet Files: Protected panel
|
||||
– Optional: "Grant Wallet Access" (time-limited, user-chosen)
|
||||
• "End Support Session" button
|
||||
• "View Audit Log" button
|
||||
│
|
||||
(User grants wallet access)
|
||||
│
|
||||
▼
|
||||
Hub: • Removes ACL deny entries
|
||||
• Records WALLET_UNLOCKED event with expiry time
|
||||
• Starts countdown timer in UI
|
||||
│
|
||||
(Timer expires or user clicks "Re-lock Wallet Now")
|
||||
│
|
||||
▼
|
||||
Hub: • Re-applies ACL deny entries
|
||||
• Removes WALLET_UNLOCK_FILE
|
||||
• Records WALLET_RELOCKED event
|
||||
│
|
||||
(User clicks "End Support Session")
|
||||
│
|
||||
▼
|
||||
Hub: 1. Removes SSH key from sovran-support authorized_keys
|
||||
2. Removes SSH key from root authorized_keys (legacy cleanup)
|
||||
3. Revokes any wallet unlock, re-applies ACL deny
|
||||
4. Verifies key is gone
|
||||
5. Records SUPPORT_DISABLED event
|
||||
│
|
||||
▼
|
||||
Modal: "Support Session Ended — SSH key removed"
|
||||
• Shows verified removal status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Incident Response
|
||||
|
||||
### Scenario 1 — You accidentally granted wallet access and are unsure what was copied
|
||||
|
||||
**Immediate steps:**
|
||||
|
||||
1. Click **"Re-lock Wallet Now"** in the Hub modal, or click
|
||||
**"End Support Session"** to simultaneously revoke SSH access and wallet
|
||||
access.
|
||||
2. Open the **Audit Log** from the Hub modal and note the timestamps of
|
||||
`WALLET_UNLOCKED` and `WALLET_RELOCKED` events.
|
||||
3. Check `/var/log/auth.log` (or `journalctl -u sshd`) for SSH login events
|
||||
by `sovran-support` during the unlocked window.
|
||||
|
||||
**Assessment:**
|
||||
|
||||
- If no SSH login occurred during the wallet-unlocked window, your keys are
|
||||
safe.
|
||||
- If an SSH login did occur, treat private keys as potentially compromised.
|
||||
|
||||
**Recovery if keys may be compromised:**
|
||||
|
||||
| Wallet | Recovery action |
|
||||
|--------|----------------|
|
||||
| LND | Move all funds out using `lncli sendcoins` to a freshly generated on-chain address; close channels; recreate wallet |
|
||||
| Sparrow | Sweep funds to a new wallet generated on an air-gapped device |
|
||||
| Bisq | Withdraw all BSQ and BTC to external wallets; delete the Bisq data directory and recreate |
|
||||
| nix-bitcoin secrets | Rotate all secrets with `nix-bitcoin-secrets generate` and redeploy |
|
||||
|
||||
**Report the incident:**
|
||||
|
||||
Contact Sovran Systems immediately at support@sovransystems.com with:
|
||||
- The audit log output (`/var/log/sovran-support-audit.log`)
|
||||
- The SSH auth log for the affected time window
|
||||
- A description of what you were troubleshooting
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2 — Support session cannot be ended (button fails or server is unresponsive)
|
||||
|
||||
**Manual key removal (run as root on the device):**
|
||||
|
||||
```bash
|
||||
# Remove from support user's authorized_keys
|
||||
rm -f /var/lib/sovran-support/.ssh/authorized_keys
|
||||
|
||||
# Remove from root's authorized_keys (fallback / legacy)
|
||||
sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys
|
||||
|
||||
# Remove wallet unlock state
|
||||
rm -f /var/lib/secrets/support-wallet-unlock
|
||||
|
||||
# Re-apply wallet ACL protections
|
||||
setfacl -R -m u:sovran-support:--- /etc/nix-bitcoin-secrets \
|
||||
/var/lib/bitcoind /var/lib/lnd /home 2>/dev/null || true
|
||||
|
||||
# Restart sshd to drop any active connections
|
||||
systemctl restart sshd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3 — You see an unexpected SUPPORT_ENABLED in the audit log
|
||||
|
||||
This should never happen without physical or remote access to the Hub web
|
||||
interface. If you see an unexpected entry:
|
||||
|
||||
1. Immediately run the manual key removal commands above.
|
||||
2. Change the Sovran Hub web interface password.
|
||||
3. Check `/var/log/nginx/access.log` (or Caddy access logs) for unexpected
|
||||
requests to `/api/support/enable`.
|
||||
4. Consider rebooting the device to clear any in-memory state.
|
||||
5. Report the incident to Sovran Systems.
|
||||
|
||||
---
|
||||
|
||||
*This document is part of the Sovran_SystemsOS repository. For the
|
||||
authoritative and up-to-date version, see the repository.*
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd /home/free/Downloads
|
||||
|
||||
|
||||
#### SCRIPT 1 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update.sh
|
||||
|
||||
rm -rf /home/free/Downloads/sovran-pro-flake-update.sh
|
||||
|
||||
|
||||
#### SCRIPT 2 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add-custom-nix.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/add-custom-nix.sh
|
||||
|
||||
rm -rf /home/free/Downloads/add-custom-nix.sh
|
||||
|
||||
|
||||
#### SCRIPT 3 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update2.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update2.sh
|
||||
|
||||
rm -rf /home/free/Downloads/sovran-pro-flake-update2.sh
|
||||
|
||||
|
||||
#### SCRIPT 4 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/nextcloud_maintenance_window_fix.sh
|
||||
|
||||
rm -rf /home/free/Downloads/nextcloud_maintenance_window_fix.sh
|
||||
|
||||
|
||||
#### SCRIPT 5 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add_external_backup_app.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/add_external_backup_app.sh
|
||||
|
||||
rm -rf /home/free/Downloads/add_external_backup_app.sh
|
||||
|
||||
|
||||
#### SCRIPT 6 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/update-agenix.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/update-agenix.sh
|
||||
|
||||
rm -rf /home/free/Downloads/update-agenix.sh
|
||||
|
||||
#### SCRIPT 7 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/element-calling_haven"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/element-calling_haven.sh
|
||||
|
||||
rm -rf /home/free/Downloads/element-calling_haven.sh
|
||||
|
||||
|
||||
#### REMOVAL OF MAIN SCRIPT ####
|
||||
|
||||
rm -rf /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add-custom-nix ; touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
touch /etc/nixos/custom.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/custom.nix <<- "EOF"
|
||||
{ config, lib, ... }:
|
||||
|
||||
{
|
||||
###########################################################
|
||||
# #
|
||||
# Sovran_SystemsOS — custom.nix #
|
||||
# #
|
||||
# Services, features, and roles are managed by the #
|
||||
# Sovran Hub. Any changes you make through the Hub #
|
||||
# will appear in the "Hub Managed" section below. #
|
||||
# #
|
||||
# If you want to add your own NixOS modules or #
|
||||
# configuration, place them here — outside of the #
|
||||
# Hub Managed section. #
|
||||
# #
|
||||
###########################################################
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run add-custom-nix"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app ; touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
cd /home/free/Downloads
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_External_Backup/sovran_systemsOS_external_backup_local_installer/sovran_systemsOS_external_backup_install.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash "sovran_systemsOS_external_backup_install.sh"
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run add_external_backup_app"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/element-calling_haven ; touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
touch /var/lib/domains/haven
|
||||
touch /var/lib/domains/element-calling
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run element-calling_haven"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix ; touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/wrappers/bin/sudo -u caddy /run/current-system/sw/bin/php /var/lib/www/nextcloud/occ config:system:set maintenance_window_start --type=integer --value=1
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run add-custom-nix"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/current-system/sw/bin/rm /etc/nixos/flake.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF"
|
||||
|
||||
{
|
||||
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
|
||||
|
||||
inputs = {
|
||||
|
||||
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
|
||||
|
||||
};
|
||||
|
||||
outputs = { self, Sovran_Systems, ... }@inputs: {
|
||||
|
||||
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
|
||||
|
||||
system = "x86_64-linux";
|
||||
|
||||
modules = [
|
||||
|
||||
./hardware-configuration.nix
|
||||
|
||||
Sovran_Systems.nixosModules.Sovran_SystemsOS
|
||||
|
||||
];
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2 ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/current-system/sw/bin/rm /etc/nixos/flake.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF"
|
||||
|
||||
{
|
||||
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
|
||||
|
||||
inputs = {
|
||||
|
||||
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
|
||||
|
||||
};
|
||||
|
||||
outputs = { self, Sovran_Systems, ... }@inputs: {
|
||||
|
||||
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
|
||||
|
||||
system = "x86_64-linux";
|
||||
|
||||
modules = [
|
||||
|
||||
./custom.nix
|
||||
|
||||
./hardware-configuration.nix
|
||||
|
||||
Sovran_Systems.nixosModules.Sovran_SystemsOS
|
||||
|
||||
];
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update2"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/update-agenix/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/update-agenix ; touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/nextclouddb.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/wordpressdb.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/turn.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrixdb.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrix_reg_secret.age
|
||||
|
||||
|
||||
pushd /var/lib/agenix-secrets/
|
||||
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/wordpressdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e wordpressdb.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/nextclouddb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e nextclouddb.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrixdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrixdb.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/turn) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e turn.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrix_reg_secret) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrix_reg_secret.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
|
||||
popd
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run update-agenix"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"bip110": {
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773169138,
|
||||
"narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"rev": "b9d018b71e20ce8c1567cbc2401b6edc2c1c7793",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"oldNixpkgs": "oldNixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774797058,
|
||||
"narHash": "sha256-URUOiKNjG3s7vDkTj554+3yzQ0qqNQoQwHdc7vs63X0=",
|
||||
"lastModified": 1783519926,
|
||||
"narHash": "sha256-2zwAN4lNitHFrHVnRZG3YcvpdtWOoF0cOBstxMeB1KI=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "a10dae067da04b7b170eed73efc665d27fc0e0c5",
|
||||
"rev": "731a1e11c2fefb14f0aa4b1f03cfa85c19c28d71",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -71,11 +52,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -106,16 +87,16 @@
|
||||
"inputs": {
|
||||
"extra-container": "extra-container",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767721199,
|
||||
"narHash": "sha256-UzRxDiJlopBGPTjyhCdMP+QdTwXK+l+y45urXCyH69A=",
|
||||
"lastModified": 1779253922,
|
||||
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
||||
"owner": "fort-nix",
|
||||
"repo": "nix-bitcoin",
|
||||
"rev": "5b532698ce9e8bd79b07d77ab4fc60e1a8408f73",
|
||||
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -127,27 +108,26 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772380631,
|
||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
||||
"lastModified": 1782911660,
|
||||
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
||||
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-25_05": {
|
||||
"locked": {
|
||||
"lastModified": 1767051569,
|
||||
"narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=",
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -159,27 +139,27 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1751274312,
|
||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||
"lastModified": 1783856661,
|
||||
"narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||
"rev": "569d578509928497eddc3fdbf94a799027050be4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-24.11",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -191,26 +171,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1772380631,
|
||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1767480499,
|
||||
"narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92",
|
||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -220,13 +185,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1775036866,
|
||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -236,13 +201,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_5": {
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1770380644,
|
||||
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
|
||||
"lastModified": 1783791668,
|
||||
"narHash": "sha256-zbcZ1dmBTPfJ7Mlqh/yLEPGpgJnwuv4Xr1xucy2WqMA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
|
||||
"rev": "716c7a2664ca8325617b8a7fbb609273f2c4cae7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -255,15 +220,15 @@
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_5",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774802402,
|
||||
"narHash": "sha256-L1UJ/zxKTyyaGGmytH6OYlgQ0HGSMhvPkvU+iz4Mkb8=",
|
||||
"lastModified": 1783941741,
|
||||
"narHash": "sha256-F+3M1IZrJa920cx2/k2AMKqedEodxLF7COJVkLJwUBo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "cbd8536a05d1aae2593cb5c9ace1010c8c5845cb",
|
||||
"rev": "e6715f01d9f56f07a27a01386b85ae22b06f0705",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -272,28 +237,11 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"oldNixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1727619874,
|
||||
"narHash": "sha256-a4Jcd+vjQAzF675/7B1LN3U2ay22jfDAVA8pOml5J/0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6710d0dd013f55809648dfb1265b8f85447d30a6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "6710d0dd013f55809648dfb1265b8f85447d30a6",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"bip110": "bip110",
|
||||
"btc-clients": "btc-clients",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
@@ -315,15 +263,16 @@
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"ref": "future-26.11",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
|
||||
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -25,27 +24,17 @@
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
self.nixosModules.Sovran_SystemsOS
|
||||
/etc/nixos/hardware-configuration.nix
|
||||
/etc/nixos/role-state.nix
|
||||
/etc/nixos/custom.nix
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
./custom.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.sovran-iso-desktop = nixpkgs.lib.nixosSystem {
|
||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/desktop.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.sovran-iso-server = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/server.nix
|
||||
./iso/common.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
@@ -66,7 +55,6 @@
|
||||
btc-clients.packages.${pkgs.system}.bisq2
|
||||
btc-clients.packages.${pkgs.system}.sparrow
|
||||
];
|
||||
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#153126"/>
|
||||
<stop offset="55%" stop-color="#0F241B"/>
|
||||
<stop offset="100%" stop-color="#091C14"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#42F39A"/>
|
||||
<stop offset="45%" stop-color="#28D978"/>
|
||||
<stop offset="100%" stop-color="#1AA45D"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#27C86F"/>
|
||||
<stop offset="100%" stop-color="#157E49"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 .18 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
|
||||
fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
|
||||
fill="none" filter="url(#innerShade)"/>
|
||||
|
||||
<path d="M128 32 A96 96 0 1 1 58 196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<path d="M128 56 A72 72 0 1 1 76 178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
|
||||
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,12 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
theme = pkgs.callPackage ./plymouth-theme.nix {};
|
||||
in
|
||||
{
|
||||
boot.plymouth.enable = true;
|
||||
boot.plymouth.theme = "sovran";
|
||||
boot.plymouth.themePackages = [ theme ];
|
||||
boot.kernelParams = [ "quiet" "splash" ];
|
||||
boot.initrd.systemd.enable = true;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ in
|
||||
{
|
||||
imports = [
|
||||
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
|
||||
./branding.nix
|
||||
];
|
||||
|
||||
image.baseName = lib.mkForce "Sovran_SystemsOS";
|
||||
@@ -62,12 +61,118 @@ in
|
||||
nixos-install-tools
|
||||
git
|
||||
curl
|
||||
openssh
|
||||
tailscale
|
||||
jq
|
||||
xxd
|
||||
];
|
||||
|
||||
# Remote install support — SSH on the live ISO
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
listenAddresses = [{ addr = "0.0.0.0"; port = 22; }];
|
||||
settings = {
|
||||
PasswordAuthentication = true;
|
||||
PermitRootLogin = "yes";
|
||||
};
|
||||
};
|
||||
users.users.root.initialPassword = lib.mkForce "sovran-remote";
|
||||
users.users.root.initialHashedPassword = lib.mkForce null;
|
||||
|
||||
# mDNS so the machine is discoverable as sovran-installer.local
|
||||
services.avahi = {
|
||||
enable = true;
|
||||
hostName = "sovran-installer";
|
||||
nssmdns4 = true;
|
||||
publish = { enable = true; addresses = true; };
|
||||
};
|
||||
|
||||
environment.etc."sovran/logo.png".source = ./assets/splash-logo.png;
|
||||
environment.etc."sovran/flake".source = sovranSource;
|
||||
environment.etc."sovran/installer.py".source = ./installer.py;
|
||||
|
||||
# These files are gitignored — set at build time by placing them in iso/secrets/
|
||||
environment.etc."sovran/enroll-token" = lib.mkIf (builtins.pathExists ./secrets/enroll-token) {
|
||||
text = builtins.readFile ./secrets/enroll-token;
|
||||
mode = "0600";
|
||||
};
|
||||
|
||||
environment.etc."sovran/provisioner-url" = lib.mkIf (builtins.pathExists ./secrets/provisioner-url) {
|
||||
text = builtins.readFile ./secrets/provisioner-url;
|
||||
mode = "0644";
|
||||
};
|
||||
|
||||
# Tailscale client for mesh VPN
|
||||
services.tailscale.enable = true;
|
||||
|
||||
# Auto-provision service — registers with provisioning server and joins Tailnet
|
||||
systemd.services.sovran-auto-provision = {
|
||||
description = "Auto-register with Sovran provisioning server and join Tailnet";
|
||||
after = [ "network-online.target" "tailscaled.service" ];
|
||||
wants = [ "network-online.target" "tailscaled.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.tailscale pkgs.curl pkgs.jq pkgs.coreutils pkgs.iproute2 pkgs.xxd ];
|
||||
script = ''
|
||||
TOKEN_FILE="/etc/sovran/enroll-token"
|
||||
URL_FILE="/etc/sovran/provisioner-url"
|
||||
|
||||
[ -f "$TOKEN_FILE" ] || { echo "No enroll token found, skipping auto-provision"; exit 0; }
|
||||
[ -f "$URL_FILE" ] || { echo "No provisioner URL found, skipping auto-provision"; exit 0; }
|
||||
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
PROV_URL=$(cat "$URL_FILE")
|
||||
[ -n "$TOKEN" ] || exit 0
|
||||
[ -n "$PROV_URL" ] || exit 0
|
||||
|
||||
# Wait for network + tailscaled
|
||||
sleep 10
|
||||
|
||||
# Collect machine info
|
||||
HOSTNAME="sovran-deploy-$(head -c 8 /dev/urandom | xxd -p)"
|
||||
MAC=$(ip link show | grep ether | head -1 | awk '{print $2}' || echo "unknown")
|
||||
|
||||
echo "Registering with provisioning server at $PROV_URL..."
|
||||
|
||||
# Retry up to 6 times (covers slow DHCP)
|
||||
RESPONSE=""
|
||||
for i in $(seq 1 6); do
|
||||
RESPONSE=$(curl -sf --max-time 15 -X POST \
|
||||
"$PROV_URL/register" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"hostname\": \"$HOSTNAME\", \"mac\": \"$MAC\"}" 2>/dev/null) && break
|
||||
echo "Attempt $i failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "$RESPONSE" ]; then
|
||||
echo "ERROR: Failed to register with provisioning server after 6 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HS_KEY=$(echo "$RESPONSE" | jq -r '.headscale_key')
|
||||
LOGIN_SERVER=$(echo "$RESPONSE" | jq -r '.login_server')
|
||||
|
||||
if [ -z "$HS_KEY" ] || [ "$HS_KEY" = "null" ]; then
|
||||
echo "ERROR: No Headscale key in response: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Joining Tailnet via $LOGIN_SERVER as $HOSTNAME..."
|
||||
tailscale up \
|
||||
--login-server="$LOGIN_SERVER" \
|
||||
--authkey="$HS_KEY" \
|
||||
--hostname="$HOSTNAME"
|
||||
|
||||
TAILSCALE_IP=$(tailscale ip -4)
|
||||
echo "Successfully joined Tailnet as $HOSTNAME ($TAILSCALE_IP)"
|
||||
'';
|
||||
};
|
||||
|
||||
environment.etc."xdg/autostart/sovran-installer.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
|
||||
@@ -5,8 +5,10 @@ gi.require_version("Adw", "1")
|
||||
from gi.repository import Gtk, Adw, GLib
|
||||
import atexit
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -14,6 +16,48 @@ LOGO = "/etc/sovran/logo.png"
|
||||
LOG = "/tmp/sovran-install.log"
|
||||
FLAKE = "/etc/sovran/flake"
|
||||
|
||||
DEPLOYED_FLAKE = """\
|
||||
{
|
||||
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
|
||||
|
||||
inputs = {
|
||||
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS?ref=stable";
|
||||
};
|
||||
|
||||
outputs = { self, Sovran_Systems, ... }@inputs: {
|
||||
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
./custom.nix
|
||||
Sovran_Systems.nixosModules.Sovran_SystemsOS
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
DICEWARE_WORDS = [
|
||||
"apple", "barn", "brook", "cabin", "cedar", "cloud", "coral", "crane",
|
||||
"delta", "eagle", "ember", "fern", "field", "flame", "flora", "flint",
|
||||
"frost", "grove", "haven", "hedge", "holly", "heron", "jade", "juniper",
|
||||
"kelp", "larch", "lemon", "lilac", "linden", "loch", "lotus", "maple",
|
||||
"marsh", "meadow", "mist", "mossy", "mount", "oak", "ocean", "olive",
|
||||
"petal", "pine", "pixel", "plum", "pond", "prism", "quartz", "raven",
|
||||
"ridge", "river", "robin", "rocky", "rose", "rowan", "sage", "sand",
|
||||
"sierra", "silver", "slate", "snow", "solar", "spark", "spruce", "stone",
|
||||
"storm", "summit", "swift", "thorn", "tide", "timber", "torch", "trout",
|
||||
"vale", "vault", "vine", "walnut", "wave", "willow", "wren", "amber",
|
||||
"aspen", "birch", "blaze", "bloom", "bluff", "coast", "copper", "crest",
|
||||
"dune", "elder", "fjord", "forge", "glade", "glen", "glow", "gulf",
|
||||
]
|
||||
|
||||
def generate_diceware_password():
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
||||
digit = secrets.randbelow(10)
|
||||
return "-".join(words) + f"-{digit}"
|
||||
|
||||
try:
|
||||
logfile = open(LOG, "a")
|
||||
atexit.register(logfile.close)
|
||||
@@ -87,7 +131,7 @@ def symbolic_icon(name):
|
||||
return icon
|
||||
|
||||
|
||||
# ── Application ────────────────────────────────────────────────────────────────
|
||||
# ── Application ──────────────────────────────────────────────────────────
|
||||
|
||||
class InstallerApp(Adw.Application):
|
||||
def __init__(self):
|
||||
@@ -99,7 +143,7 @@ class InstallerApp(Adw.Application):
|
||||
self.win.present()
|
||||
|
||||
|
||||
# ── Main Window ────────────────────────────────────────────────────────────────
|
||||
# ── Main Window ──────────────────────────────────────────────────────────
|
||||
|
||||
class InstallerWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, **kwargs):
|
||||
@@ -113,6 +157,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
self.boot_size = None
|
||||
self.data_disk = None
|
||||
self.data_size = None
|
||||
self.data_drive_has_timechain = False
|
||||
self.free_password = None
|
||||
|
||||
# Root navigation view
|
||||
self.nav = Adw.NavigationView()
|
||||
@@ -146,7 +192,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
break
|
||||
self.push_page(title, child)
|
||||
|
||||
# ── Shared widgets ───────────────��─────────────────────────────────────
|
||||
# ── Shared widgets ────────────────────────────────────────────────────
|
||||
|
||||
def make_scrolled_log(self):
|
||||
sw = Gtk.ScrolledWindow()
|
||||
@@ -216,7 +262,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
pass
|
||||
|
||||
title = Gtk.Label()
|
||||
title.set_markup("<span size='xx-large' weight='heavy'>Welcome to Sovran SystemsOS</span>")
|
||||
title.set_markup("<span size='xx-large' weight='heavy'>Welcome to Sovran_SystemsOS</span>")
|
||||
title.set_margin_top(8)
|
||||
hero.append(title)
|
||||
|
||||
@@ -237,7 +283,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
notice.set_markup(
|
||||
"<span size='medium'>"
|
||||
"Before installation begins, please ensure you have an <b>active internet connection</b>.\n"
|
||||
"Sovran SystemsOS downloads packages during installation and requires internet access\n"
|
||||
"Sovran_SystemsOS downloads packages during installation and requires internet access\n"
|
||||
"to complete the process. Connect via <b>Ethernet cable</b> or configure <b>Wi-Fi</b> now."
|
||||
"</span>"
|
||||
)
|
||||
@@ -303,7 +349,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
pass
|
||||
|
||||
title = Gtk.Label()
|
||||
title.set_markup("<span size='xx-large' weight='heavy'>Sovran Systems</span>")
|
||||
title.set_markup("<span size='xx-large' weight='heavy'>Sovran_SystemsOS</span>")
|
||||
hero.append(title)
|
||||
|
||||
sub = Gtk.Label()
|
||||
@@ -317,6 +363,40 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
sep.set_margin_end(40)
|
||||
outer.append(sep)
|
||||
|
||||
notice_frame = Gtk.Frame()
|
||||
notice_frame.add_css_class("card")
|
||||
notice_frame.set_margin_start(40)
|
||||
notice_frame.set_margin_end(40)
|
||||
notice_frame.set_margin_top(20)
|
||||
notice_frame.set_margin_bottom(4)
|
||||
|
||||
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
notice_box.set_margin_top(12)
|
||||
notice_box.set_margin_bottom(12)
|
||||
notice_box.set_margin_start(16)
|
||||
notice_box.set_margin_end(16)
|
||||
|
||||
notice_icon = symbolic_icon("dialog-information-symbolic")
|
||||
notice_icon.set_valign(Gtk.Align.START)
|
||||
notice_box.append(notice_icon)
|
||||
|
||||
notice_lbl = Gtk.Label()
|
||||
notice_lbl.set_use_markup(True)
|
||||
notice_lbl.set_wrap(True)
|
||||
notice_lbl.set_xalign(0)
|
||||
notice_lbl.set_halign(Gtk.Align.FILL)
|
||||
notice_lbl.set_markup(
|
||||
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
|
||||
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
|
||||
"• The ability to open / forward ports on your router\n\n"
|
||||
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
|
||||
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
|
||||
)
|
||||
notice_box.append(notice_lbl)
|
||||
|
||||
notice_frame.set_child(notice_box)
|
||||
outer.append(notice_frame)
|
||||
|
||||
# Role label
|
||||
role_lbl = Gtk.Label()
|
||||
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
||||
@@ -623,11 +703,19 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
def push_disk_confirm(self):
|
||||
"""Show the selected drives and ask the user to type ERASE to confirm."""
|
||||
self.data_drive_has_timechain = False
|
||||
if self.data_disk:
|
||||
data_path = f"/dev/{self.data_disk}"
|
||||
self.data_drive_has_timechain = self.detect_existing_timechain_data(data_path)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
# Disk info group
|
||||
disk_group = Adw.PreferencesGroup()
|
||||
disk_group.set_title("Drives to be erased")
|
||||
if self.data_disk and self.data_drive_has_timechain:
|
||||
disk_group.set_title("OS drive to be erased (data drive preserved)")
|
||||
else:
|
||||
disk_group.set_title("Drives to be erased")
|
||||
disk_group.set_margin_top(24)
|
||||
disk_group.set_margin_start(40)
|
||||
disk_group.set_margin_end(40)
|
||||
@@ -644,12 +732,21 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
data_row.set_subtitle(f"/dev/{self.data_disk} — {human_size(self.data_size)}")
|
||||
data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
disk_group.add(data_row)
|
||||
if self.data_drive_has_timechain:
|
||||
note_row = Adw.ActionRow()
|
||||
note_row.set_title(f"Existing Bitcoin timechain detected on /dev/{self.data_disk}")
|
||||
note_row.set_subtitle("Data will be preserved and mounted as-is.")
|
||||
note_row.add_prefix(symbolic_icon("emblem-ok-symbolic"))
|
||||
disk_group.add(note_row)
|
||||
|
||||
outer.append(disk_group)
|
||||
|
||||
# Warning banner
|
||||
banner = Adw.Banner()
|
||||
banner.set_title("⚠ All data on the above disk(s) will be permanently destroyed.")
|
||||
if self.data_disk and self.data_drive_has_timechain:
|
||||
banner.set_title("⚠ All data on the OS disk will be permanently destroyed. Existing Bitcoin data disk will be preserved.")
|
||||
else:
|
||||
banner.set_title("⚠ All data on the above disk(s) will be permanently destroyed.")
|
||||
banner.set_revealed(True)
|
||||
banner.set_margin_top(16)
|
||||
banner.set_margin_start(40)
|
||||
@@ -731,9 +828,61 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
# ── Worker: partition ─────────────────────────────────────────────────
|
||||
|
||||
def partition_path(self, dev_path, num):
|
||||
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
|
||||
|
||||
def detect_existing_timechain_data(self, data_path, buf=None):
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
if not os.path.exists(data_p1):
|
||||
return False
|
||||
|
||||
label = ""
|
||||
for cmd in (
|
||||
["sudo", "lsblk", "-no", "LABEL", data_p1],
|
||||
["sudo", "blkid", "-o", "value", "-s", "LABEL", data_p1],
|
||||
):
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode == 0:
|
||||
stdout = proc.stdout.strip()
|
||||
label = stdout.splitlines()[0] if stdout else ""
|
||||
if label:
|
||||
break
|
||||
|
||||
if label != "BTCEcoandBackup":
|
||||
return False
|
||||
|
||||
check_mount = tempfile.mkdtemp(prefix="sovran-installer-data-check-")
|
||||
mounted = False
|
||||
try:
|
||||
run(["sudo", "mount", "-o", "ro", data_p1, check_mount])
|
||||
mounted = True
|
||||
|
||||
has_bitcoin = os.path.isdir(f"{check_mount}/BTCEcoandBackup/Bitcoin_Node")
|
||||
has_electrs = os.path.isdir(f"{check_mount}/BTCEcoandBackup/Electrs_Data")
|
||||
if has_bitcoin and has_electrs:
|
||||
if buf is not None:
|
||||
GLib.idle_add(
|
||||
append_text,
|
||||
buf,
|
||||
"=== Existing Bitcoin timechain detected on data drive — preserving data ===\n",
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"Timechain detection failed for {data_p1} at mount/check step ({check_mount}): {e}")
|
||||
return False
|
||||
finally:
|
||||
if mounted:
|
||||
subprocess.run(["sudo", "umount", check_mount], capture_output=True, text=True)
|
||||
subprocess.run(["sudo", "rmdir", check_mount], capture_output=True, text=True)
|
||||
|
||||
def do_partition(self, buf):
|
||||
boot_path = f"/dev/{self.boot_disk}"
|
||||
data_path = f"/dev/{self.data_disk}" if self.data_disk else None
|
||||
self.data_drive_has_timechain = False
|
||||
|
||||
if data_path:
|
||||
self.data_drive_has_timechain = self.detect_existing_timechain_data(data_path, buf)
|
||||
|
||||
# ── Wipe disk(s) ──
|
||||
GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n")
|
||||
@@ -741,12 +890,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
run_stream(["sudo", "sgdisk", "--zap-all", boot_path], buf)
|
||||
run_stream(["sudo", "wipefs", "--all", "--force", boot_path], buf)
|
||||
|
||||
if data_path:
|
||||
if data_path and not self.data_drive_has_timechain:
|
||||
run_stream(["sudo", "sgdisk", "--zap-all", data_path], buf)
|
||||
run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf)
|
||||
|
||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||
if data_path:
|
||||
if data_path and not self.data_drive_has_timechain:
|
||||
run_stream(["sudo", "partprobe", data_path], buf)
|
||||
|
||||
time.sleep(2)
|
||||
@@ -762,7 +911,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
time.sleep(2)
|
||||
|
||||
# ── Partition data disk (if selected) ──
|
||||
if data_path:
|
||||
if data_path and not self.data_drive_has_timechain:
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning data disk ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary",
|
||||
@@ -773,14 +922,14 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
# ── Format partitions ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Formatting partitions ===\n")
|
||||
boot_p1 = f"{boot_path}p1" if "nvme" in boot_path else f"{boot_path}1"
|
||||
boot_p2 = f"{boot_path}p2" if "nvme" in boot_path else f"{boot_path}2"
|
||||
boot_p1 = self.partition_path(boot_path, 1)
|
||||
boot_p2 = self.partition_path(boot_path, 2)
|
||||
|
||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||
|
||||
if data_path:
|
||||
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
|
||||
if data_path and not self.data_drive_has_timechain:
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf)
|
||||
|
||||
# ── Mount filesystems ──
|
||||
@@ -790,9 +939,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||
|
||||
if data_path:
|
||||
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive"], buf)
|
||||
run_stream(["sudo", "mount", data_p1, "/mnt/run/media/Second_Drive"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup"], buf)
|
||||
|
||||
GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n")
|
||||
run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf)
|
||||
@@ -830,8 +982,9 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}")
|
||||
run(["sudo", "cp", "/mnt/etc/nixos/custom.template.nix", "/mnt/etc/nixos/custom.nix"])
|
||||
run(["sudo", "chmod", "644", "/mnt/etc/nixos/custom.nix"])
|
||||
|
||||
# ── Step 4: Ready to install ────────���──────────────────────────────────
|
||||
# ── Step 4: Ready to install ──────────────────────────────────────────
|
||||
|
||||
def push_ready(self):
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
@@ -882,7 +1035,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
outer.append(self.nav_row(
|
||||
next_label="Install Now",
|
||||
next_cb=lambda b: self.push_progress(
|
||||
"Installing Sovran SystemsOS",
|
||||
"Installing Sovran_SystemsOS",
|
||||
"Building and installing your system. Please wait...",
|
||||
self.do_install
|
||||
)
|
||||
@@ -930,57 +1083,98 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
path = os.path.join(nixos_dir, entry)
|
||||
run(["sudo", "rm", "-rf", path])
|
||||
|
||||
GLib.idle_add(append_text, buf, "Writing deployed flake.nix...\n")
|
||||
proc = subprocess.run(
|
||||
["sudo", "tee", "/mnt/etc/nixos/flake.nix"],
|
||||
input=DEPLOYED_FLAKE,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
log(proc.stdout)
|
||||
if proc.returncode != 0:
|
||||
log(proc.stderr)
|
||||
raise RuntimeError(proc.stderr.strip() or "Failed to write deployed flake.nix")
|
||||
GLib.idle_add(append_text, buf, "Locking flake to stable...\n")
|
||||
run_stream(["sudo", "nix", "--extra-experimental-features", "nix-command flakes",
|
||||
"flake", "lock", "/mnt/etc/nixos"], buf)
|
||||
|
||||
# Generate diceware passwords and write them to the installed system
|
||||
GLib.idle_add(append_text, buf, "Setting up user passwords...\n")
|
||||
self.free_password = generate_diceware_password()
|
||||
root_password = generate_diceware_password()
|
||||
|
||||
run(["sudo", "mkdir", "-p", "/mnt/var/lib/secrets"])
|
||||
run(["sudo", "chmod", "700", "/mnt/var/lib/secrets"])
|
||||
proc = subprocess.run(
|
||||
["sudo", "tee", "/mnt/var/lib/secrets/free-password"],
|
||||
input=self.free_password, capture_output=True, text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log(proc.stderr)
|
||||
raise RuntimeError(proc.stderr.strip() or "Failed to write free-password")
|
||||
run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"])
|
||||
|
||||
proc = subprocess.run(
|
||||
["sudo", "tee", "/mnt/var/lib/secrets/root-password"],
|
||||
input=root_password, capture_output=True, text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log(proc.stderr)
|
||||
raise RuntimeError(proc.stderr.strip() or "Failed to write root-password")
|
||||
run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/root-password"])
|
||||
|
||||
GLib.idle_add(self.push_complete)
|
||||
|
||||
# ── Step 6: Complete ───────────────────────────────────────────────────
|
||||
# ── Complete ───────────────────────────────────────────────────────────
|
||||
|
||||
def push_complete(self):
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
status = Adw.StatusPage()
|
||||
status.set_title("Installation Complete!")
|
||||
status.set_description("Welcome to Sovran SystemsOS.")
|
||||
status.set_vexpand(True)
|
||||
status.set_description("Before rebooting, write down your login password.")
|
||||
status.set_icon_name("dialog-password-symbolic")
|
||||
outer.append(status)
|
||||
|
||||
creds_group = Adw.PreferencesGroup()
|
||||
creds_group.set_title("⚠ Write down your login details before rebooting")
|
||||
creds_group.set_margin_start(40)
|
||||
creds_group.set_margin_end(40)
|
||||
|
||||
user_row = Adw.ActionRow()
|
||||
user_row.set_title("Username")
|
||||
user_row.set_subtitle("free")
|
||||
creds_group.add(user_row)
|
||||
|
||||
pass_row = Adw.ActionRow()
|
||||
pass_row.set_title("Password")
|
||||
pass_row.set_subtitle("free")
|
||||
creds_group.add(pass_row)
|
||||
|
||||
note_row = Adw.ActionRow()
|
||||
note_row.set_title("App Passwords")
|
||||
note_row.set_subtitle(
|
||||
"After rebooting, all app passwords (Nextcloud, Bitcoin, Matrix, etc.) "
|
||||
"will be available in the Sovran Hub on your dashboard."
|
||||
pw_frame = Gtk.Frame()
|
||||
pw_frame.set_margin_start(60)
|
||||
pw_frame.set_margin_end(60)
|
||||
pw_label = Gtk.Label()
|
||||
pw_label.set_markup(
|
||||
f"<span font_family='monospace' size='xx-large' weight='bold'>"
|
||||
f"{GLib.markup_escape_text(self.free_password)}</span>"
|
||||
)
|
||||
creds_group.add(note_row)
|
||||
pw_label.set_selectable(True)
|
||||
pw_label.set_margin_top(16)
|
||||
pw_label.set_margin_bottom(16)
|
||||
pw_frame.set_child(pw_label)
|
||||
outer.append(pw_frame)
|
||||
|
||||
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||
content_box.append(status)
|
||||
content_box.append(creds_group)
|
||||
outer.append(content_box)
|
||||
warning = Gtk.Label()
|
||||
warning.set_markup(
|
||||
"<span foreground='#e8a838' size='medium' weight='bold'>"
|
||||
"⚠ Write this password down now.\n"
|
||||
"You will need it to log in to your computer and the Sovran Hub.\n"
|
||||
"This password cannot be recovered.</span>"
|
||||
)
|
||||
warning.set_justify(Gtk.Justification.CENTER)
|
||||
warning.set_wrap(True)
|
||||
warning.set_margin_top(20)
|
||||
warning.set_margin_start(48)
|
||||
warning.set_margin_end(48)
|
||||
outer.append(warning)
|
||||
|
||||
reboot_btn = Gtk.Button(label="Reboot Now")
|
||||
reboot_btn.add_css_class("success")
|
||||
outer.append(Gtk.Label(label="", vexpand=True))
|
||||
|
||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
btn_box.set_halign(Gtk.Align.CENTER)
|
||||
btn_box.set_margin_bottom(32)
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
|
||||
reboot_btn.add_css_class("suggested-action")
|
||||
reboot_btn.add_css_class("pill")
|
||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||
|
||||
nav = Gtk.Box()
|
||||
nav.set_margin_bottom(24)
|
||||
nav.set_margin_end(40)
|
||||
nav.set_halign(Gtk.Align.END)
|
||||
nav.append(reboot_btn)
|
||||
outer.append(nav)
|
||||
btn_box.append(reboot_btn)
|
||||
outer.append(btn_box)
|
||||
|
||||
self.push_page("Complete", outer)
|
||||
return False
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{ pkgs, lib }:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "sovran-plymouth-theme";
|
||||
version = "1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/share/plymouth/themes/sovran
|
||||
cp ${./assets/splash-logo.png} $out/share/plymouth/themes/sovran/logo.png
|
||||
|
||||
cat > $out/share/plymouth/themes/sovran/sovran.plymouth <<EOF
|
||||
[Plymouth Theme]
|
||||
Name=Sovran Systems
|
||||
Description=Sovran Systems Splash
|
||||
ModuleName=script
|
||||
|
||||
[script]
|
||||
ImageDir=$out/share/plymouth/themes/sovran
|
||||
ScriptFile=$out/share/plymouth/themes/sovran/sovran.script
|
||||
EOF
|
||||
|
||||
cat > $out/share/plymouth/themes/sovran/sovran.script <<'EOF'
|
||||
# Background color: #CFFFD7 (RGB 207,255,215)
|
||||
bg_r = 207/255.0
|
||||
bg_g = 255/255.0
|
||||
bg_b = 215/255.0
|
||||
|
||||
Window.SetBackgroundTopColor (bg_r, bg_g, bg_b);
|
||||
Window.SetBackgroundBottomColor (bg_r, bg_g, bg_b);
|
||||
|
||||
logo = Image("logo.png");
|
||||
logo_sprite = Sprite(logo);
|
||||
logo_sprite.SetX((Window.GetWidth() - logo.GetWidth()) / 2);
|
||||
logo_sprite.SetY((Window.GetHeight() - logo.GetHeight()) / 2);
|
||||
EOF
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env bash
|
||||
# sovran-install-headless.sh — Non-interactive remote installer for Sovran_SystemsOS
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: sovran-install-headless.sh [OPTIONS]
|
||||
|
||||
Options:
|
||||
--disk /dev/sda Target OS disk (required)
|
||||
--data-disk /dev/sdb Data disk for Bitcoin (optional)
|
||||
--role server|desktop|node Installation role (default: server)
|
||||
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
||||
--headscale-server URL Headscale login server for post-install Tailnet
|
||||
--headscale-key KEY Headscale pre-auth key for the installed OS
|
||||
USAGE
|
||||
}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
DISK=""
|
||||
DATA_DISK=""
|
||||
ROLE="server"
|
||||
DEPLOY_KEY=""
|
||||
HEADSCALE_SERVER=""
|
||||
HEADSCALE_KEY=""
|
||||
DATA_DISK_HAS_TIMECHAIN=false
|
||||
|
||||
FLAKE="/etc/sovran/flake"
|
||||
LOG="/tmp/sovran-headless-install.log"
|
||||
|
||||
BYTES_256GB=$((256 * 1024 * 1024 * 1024))
|
||||
BYTES_2TB=$((2 * 1000 * 1000 * 1000 * 1000))
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
die() {
|
||||
log "ERROR: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--disk) DISK="$2"; shift 2 ;;
|
||||
--data-disk) DATA_DISK="$2"; shift 2 ;;
|
||||
--role) ROLE="$2"; shift 2 ;;
|
||||
--deploy-key) DEPLOY_KEY="$2"; shift 2 ;;
|
||||
--headscale-server) HEADSCALE_SERVER="$2"; shift 2 ;;
|
||||
--headscale-key) HEADSCALE_KEY="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) die "Unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validate required arguments ───────────────────────────────────────────────
|
||||
[[ -n "$DISK" ]] || die "--disk is required"
|
||||
|
||||
case "$ROLE" in
|
||||
server|desktop|node) ;;
|
||||
*) die "--role must be one of: server, desktop, node" ;;
|
||||
esac
|
||||
|
||||
# ── Validate disk existence and size ─────────────────────────────────────────
|
||||
log "=== Validating disks ==="
|
||||
|
||||
[[ -b "$DISK" ]] || die "OS disk not found: $DISK"
|
||||
|
||||
disk_size_bytes() {
|
||||
local dev="$1"
|
||||
lsblk -b -dno SIZE "$dev" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
OS_SIZE=$(disk_size_bytes "$DISK")
|
||||
log "OS disk $DISK: $OS_SIZE bytes"
|
||||
[[ "$OS_SIZE" -ge "$BYTES_256GB" ]] \
|
||||
|| die "OS disk $DISK is too small ($(( OS_SIZE / 1024 / 1024 / 1024 )) GB). Minimum is 256 GB."
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
[[ -b "$DATA_DISK" ]] || die "Data disk not found: $DATA_DISK"
|
||||
[[ "$DATA_DISK" != "$DISK" ]] || die "OS disk and data disk cannot be the same device"
|
||||
DATA_SIZE=$(disk_size_bytes "$DATA_DISK")
|
||||
log "Data disk $DATA_DISK: $DATA_SIZE bytes"
|
||||
[[ "$DATA_SIZE" -ge "$BYTES_2TB" ]] \
|
||||
|| die "Data disk $DATA_DISK is too small ($(( DATA_SIZE / 1024 / 1024 / 1024 )) GB). Minimum is 2 TB."
|
||||
fi
|
||||
|
||||
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
||||
part_suffix() {
|
||||
local dev="$1" n="$2"
|
||||
if [[ "$dev" == *nvme* ]]; then
|
||||
echo "${dev}p${n}"
|
||||
else
|
||||
echo "${dev}${n}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
if [[ -b "$DATA_P1" ]]; then
|
||||
DATA_LABEL=$(lsblk -no LABEL "$DATA_P1" 2>/dev/null | head -n1 || true)
|
||||
if [[ -z "$DATA_LABEL" ]]; then
|
||||
DATA_LABEL=$(blkid -o value -s LABEL "$DATA_P1" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ "$DATA_LABEL" == "BTCEcoandBackup" ]]; then
|
||||
CHECK_MOUNT=$(mktemp -d /tmp/sovran-data-check.XXXXXX)
|
||||
if mount -o ro "$DATA_P1" "$CHECK_MOUNT" 2>/dev/null; then
|
||||
if [[ -d "$CHECK_MOUNT/BTCEcoandBackup/Bitcoin_Node" && -d "$CHECK_MOUNT/BTCEcoandBackup/Electrs_Data" ]]; then
|
||||
DATA_DISK_HAS_TIMECHAIN=true
|
||||
log "Existing Bitcoin timechain detected on data drive — preserving data"
|
||||
fi
|
||||
umount "$CHECK_MOUNT" || true
|
||||
fi
|
||||
rmdir "$CHECK_MOUNT" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 1: Wipe disks ────────────────────────────────────────────────────────
|
||||
log "=== Wiping disk(s) ==="
|
||||
|
||||
sgdisk --zap-all "$DISK"
|
||||
wipefs --all --force "$DISK"
|
||||
|
||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||
sgdisk --zap-all "$DATA_DISK"
|
||||
wipefs --all --force "$DATA_DISK"
|
||||
fi
|
||||
|
||||
partprobe "$DISK"
|
||||
[[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]] && partprobe "$DATA_DISK"
|
||||
sleep 2
|
||||
|
||||
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
||||
log "=== Partitioning OS disk ==="
|
||||
|
||||
sgdisk \
|
||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
|
||||
partprobe "$DISK"
|
||||
sleep 2
|
||||
|
||||
# ── Step 3: Partition data disk (if present) ──────────────────────────────────
|
||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||
log "=== Partitioning data disk ==="
|
||||
sgdisk \
|
||||
-n "1:1M:0" -t "1:8300" -c "1:primary" \
|
||||
"$DATA_DISK"
|
||||
partprobe "$DATA_DISK"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# ── Step 4: Format partitions ─────────────────────────────────────────────────
|
||||
log "=== Formatting partitions ==="
|
||||
|
||||
BOOT_P1=$(part_suffix "$DISK" 1)
|
||||
BOOT_P2=$(part_suffix "$DISK" 2)
|
||||
|
||||
mkfs.vfat -F 32 "$BOOT_P1"
|
||||
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
||||
|
||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
mkfs.ext4 -F -L BTCEcoandBackup "$DATA_P1"
|
||||
fi
|
||||
|
||||
# ── Step 5: Mount filesystems ─────────────────────────────────────────────────
|
||||
log "=== Mounting filesystems ==="
|
||||
|
||||
mount "$BOOT_P2" /mnt
|
||||
mkdir -p /mnt/boot/efi
|
||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
mkdir -p /mnt/run/media/Second_Drive
|
||||
mount "$DATA_P1" /mnt/run/media/Second_Drive
|
||||
|
||||
# ── Step 6: Create Bitcoin data directories ─────────────────────────────
|
||||
log "=== Creating Bitcoin data directories ==="
|
||||
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup
|
||||
fi
|
||||
|
||||
# ── Step 7: Generate hardware config ─────────────────────────────────────────
|
||||
log "=== Generating hardware config ==="
|
||||
nixos-generate-config --root /mnt
|
||||
|
||||
# ── Step 8: Copy flake source ─────────────────────────────────────────────────
|
||||
log "=== Copying flake to /mnt ==="
|
||||
cp /mnt/etc/nixos/hardware-configuration.nix /tmp/hardware-configuration.nix
|
||||
rm -rf /mnt/etc/nixos/
|
||||
mkdir -p /mnt/etc/nixos
|
||||
cp -a "${FLAKE}/." /mnt/etc/nixos/
|
||||
cp /tmp/hardware-configuration.nix /mnt/etc/nixos/hardware-configuration.nix
|
||||
|
||||
# ── Step 9: Write role-state.nix ─────────────────────────────────────────────
|
||||
log "=== Writing role config ==="
|
||||
|
||||
case "$ROLE" in
|
||||
server)
|
||||
IS_SERVER=true; IS_DESKTOP=false; IS_NODE=false ;;
|
||||
desktop)
|
||||
IS_SERVER=false; IS_DESKTOP=true; IS_NODE=false ;;
|
||||
node)
|
||||
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
||||
esac
|
||||
|
||||
cat > /mnt/etc/nixos/role-state.nix <<EOF
|
||||
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
||||
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
||||
log "=== Writing custom.nix ==="
|
||||
|
||||
if [[ -n "$DEPLOY_KEY" || -n "$HEADSCALE_SERVER" ]]; then
|
||||
{
|
||||
echo '{ config, lib, ... }:'
|
||||
echo '{'
|
||||
echo ' sovran_systemsOS.deploy = {'
|
||||
echo ' enable = true;'
|
||||
[[ -n "$DEPLOY_KEY" ]] && echo " authorizedKey = \"${DEPLOY_KEY}\";"
|
||||
[[ -n "$HEADSCALE_SERVER" ]] && echo " headscaleServer = \"${HEADSCALE_SERVER}\";"
|
||||
echo ' };'
|
||||
echo '}'
|
||||
} > /mnt/etc/nixos/custom.nix
|
||||
else
|
||||
cp /mnt/etc/nixos/custom.template.nix /mnt/etc/nixos/custom.nix
|
||||
chmod 644 /mnt/etc/nixos/custom.nix
|
||||
fi
|
||||
|
||||
# ── Write Headscale auth key if provided ─────────────────────────────────────
|
||||
if [[ -n "$HEADSCALE_KEY" ]]; then
|
||||
mkdir -p /mnt/var/lib/secrets
|
||||
echo "$HEADSCALE_KEY" > /mnt/var/lib/secrets/headscale-authkey
|
||||
chmod 600 /mnt/var/lib/secrets/headscale-authkey
|
||||
log "Headscale auth key written to /mnt/var/lib/secrets/headscale-authkey"
|
||||
fi
|
||||
|
||||
# ── Step 11: Copy configs to host for flake evaluation ───────────────────────
|
||||
log "=== Copying config files to host /etc/nixos for flake evaluation ==="
|
||||
mkdir -p /etc/nixos
|
||||
cp /mnt/etc/nixos/role-state.nix /etc/nixos/role-state.nix
|
||||
cp /mnt/etc/nixos/custom.nix /etc/nixos/custom.nix
|
||||
cp /mnt/etc/nixos/hardware-configuration.nix /etc/nixos/hardware-configuration.nix
|
||||
|
||||
# ── Step 12: Run nixos-install ────────────────────────────────────────────────
|
||||
log "=== Running nixos-install ==="
|
||||
nixos-install \
|
||||
--root /mnt \
|
||||
--flake /mnt/etc/nixos#nixos \
|
||||
--no-root-password \
|
||||
--impure
|
||||
|
||||
log "=== Installation complete! ==="
|
||||
log "You can now reboot into Sovran_SystemsOS."
|
||||
log "After reboot, the machine will be accessible via SSH on port 22 (if --deploy-key was provided)."
|
||||
[[ -n "$HEADSCALE_SERVER" ]] && \
|
||||
log "Tailscale will connect to Headscale at ${HEADSCALE_SERVER} on first boot."
|
||||
@@ -1,24 +0,0 @@
|
||||
{config, pkgs, lib, ...}:
|
||||
|
||||
{
|
||||
|
||||
systemd.services.Sovran_SystemsOS_File_Fixes_And_New_Services = {
|
||||
|
||||
unitConfig = {
|
||||
After = "btcpayserver.service";
|
||||
Requires = "network-online.target";
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
ExecStartPre= "/run/current-system/sw/bin/sleep 30";
|
||||
ExecStart = "/run/current-system/sw/bin/wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh -O /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh ; /run/current-system/sw/bin/bash /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh";
|
||||
RemainAfterExit = "yes";
|
||||
User = "root";
|
||||
Type = "oneshot";
|
||||
};
|
||||
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.sovran_systemsOS;
|
||||
in
|
||||
{
|
||||
options.sovran_systemsOS.packages.bip110 = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = "BIP110 Bitcoin package";
|
||||
};
|
||||
|
||||
config = lib.mkIf (
|
||||
cfg.features.bip110 &&
|
||||
cfg.packages.bip110 != null
|
||||
) {
|
||||
services.bitcoind.package = lib.mkForce cfg.packages.bip110;
|
||||
|
||||
environment.systemPackages = [
|
||||
cfg.packages.bip110
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
package = config.nix-bitcoin.pkgs.bitcoind-knots;
|
||||
package = pkgs.bitcoind-knots;
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||
txindex = true;
|
||||
tor.proxy = true;
|
||||
@@ -55,7 +55,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
};
|
||||
|
||||
services.btcpayserver = {
|
||||
enable = true;
|
||||
enable = config.sovran_systemsOS.web.btcpayserver;
|
||||
};
|
||||
|
||||
services.btcpayserver.lightningBackend = "lnd";
|
||||
@@ -69,8 +69,48 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
};
|
||||
|
||||
nix-bitcoin.useVersionLockedPkgs = false;
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
|
||||
systemd.services.bitcoind = {
|
||||
requires = [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
||||
};
|
||||
|
||||
systemd.services.electrs = {
|
||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
};
|
||||
|
||||
systemd.services.lnd = {
|
||||
wants = [ "bitcoind.service" ];
|
||||
# requires for bitcoind set by nix-bitcoin; mkForce removes it
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
systemd.services.sovran-btc-permissions = {
|
||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
before = [ "bitcoind.service" "electrs.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||
fi
|
||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 3051 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3051 ];
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,13 +2,46 @@
|
||||
|
||||
let
|
||||
exposeBtcpay = config.sovran_systemsOS.web.btcpayserver;
|
||||
extraVhosts = config.sovran_systemsOS.caddy.extraVirtualHosts;
|
||||
|
||||
# True when any service needs HTTPS/ACME (domain-based vhosts)
|
||||
needsHttpsPorts =
|
||||
config.sovran_systemsOS.web.btcpayserver
|
||||
|| config.sovran_systemsOS.services.synapse
|
||||
|| config.sovran_systemsOS.services.wordpress
|
||||
|| config.sovran_systemsOS.services.nextcloud
|
||||
|| config.sovran_systemsOS.services.vaultwarden
|
||||
|| config.sovran_systemsOS.features.haven
|
||||
|| config.sovran_systemsOS.features.element-calling;
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
# Only enable Caddy when at least one domain-based service needs it or
|
||||
# the operator has defined custom vhosts. This prevents Caddy from
|
||||
# running on Desktop Only installs that have no web services configured.
|
||||
enable = needsHttpsPorts || extraVhosts != "";
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
configFile = "/run/caddy/Caddyfile";
|
||||
};
|
||||
|
||||
# Only open ports 80/443 when at least one domain-based service is active
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf needsHttpsPorts [ 80 443 ];
|
||||
networking.firewall.allowedUDPPorts = lib.mkIf needsHttpsPorts [ 80 443 ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/domains 0755 caddy root -"
|
||||
];
|
||||
|
||||
# Override ExecStart + ExecReload to point at the runtime-generated Caddyfile
|
||||
systemd.services.caddy.serviceConfig = {
|
||||
ExecStart = lib.mkForce [
|
||||
""
|
||||
"${pkgs.caddy}/bin/caddy run --config /run/caddy/Caddyfile --adapter caddyfile"
|
||||
];
|
||||
ExecReload = lib.mkForce [
|
||||
""
|
||||
"${pkgs.caddy}/bin/caddy reload --config /run/caddy/Caddyfile --adapter caddyfile --force"
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services.caddy-generate-config = {
|
||||
@@ -39,12 +72,20 @@ in
|
||||
HAVEN=$(read_domain haven)
|
||||
ACME_EMAIL=$(read_domain sslemail)
|
||||
|
||||
# Start with global config
|
||||
# Start with global config — use ACME only when domain-based services are active
|
||||
${if needsHttpsPorts then ''
|
||||
cat > /run/caddy/Caddyfile <<EOF
|
||||
{
|
||||
email $ACME_EMAIL
|
||||
}
|
||||
EOF
|
||||
'' else ''
|
||||
cat > /run/caddy/Caddyfile <<EOF
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
EOF
|
||||
''}
|
||||
|
||||
# ── Matrix ──────────────────────────────────────
|
||||
if [ -n "$MATRIX" ]; then
|
||||
@@ -56,10 +97,10 @@ EOF
|
||||
$MATRIX {
|
||||
reverse_proxy /_matrix/* http://localhost:8008
|
||||
reverse_proxy /_synapse/client/* http://localhost:8008
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
respond \`{"m.server":"$MATRIX:443"}\` 200
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -72,7 +113,7 @@ EOF
|
||||
$WORDPRESS {
|
||||
encode gzip zstd
|
||||
root * /var/lib/www/wordpress
|
||||
php_fastcgi unix//run/phpfpm/mypool.sock
|
||||
php_fastcgi unix//run/phpfpm/wordpress.sock
|
||||
file_server browse
|
||||
}
|
||||
EOF
|
||||
@@ -85,7 +126,7 @@ EOF
|
||||
$NEXTCLOUD {
|
||||
encode gzip zstd
|
||||
root * /var/lib/www/nextcloud
|
||||
php_fastcgi unix//run/phpfpm/mypool.sock {
|
||||
php_fastcgi unix//run/phpfpm/nextcloud.sock {
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
file_server
|
||||
@@ -150,6 +191,12 @@ EOF
|
||||
|
||||
http://sovransystemsos.local {
|
||||
reverse_proxy localhost:8937
|
||||
header {
|
||||
Clear-Site-Data "\"cache\""
|
||||
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Pragma "no-cache"
|
||||
Expires "0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -170,6 +217,11 @@ EOF
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Custom vhosts from custom.nix ──────────────
|
||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||
${extraVhosts}
|
||||
CUSTOM_VHOSTS_EOF
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# ── modules/core/cpu-performance.nix ──────────────────────────────────────────
|
||||
# Forces all CPU cores to run at maximum frequency on node and server_plus_desktop
|
||||
# roles. Desktop-only installs retain normal OS power management behaviour.
|
||||
#
|
||||
# Three layers:
|
||||
# 1. power-profiles-daemon disabled — removes the GNOME power profile picker;
|
||||
# no user can switch profiles
|
||||
# 2. cpufreq performance governor — pins every core to max frequency via
|
||||
# kernel, enforced at boot by a oneshot unit
|
||||
# 3. systemd oneshot enforcement — belt-and-suspenders; applies the governor
|
||||
# after every boot even if module loads late
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
config = lib.mkIf (!config.sovran_systemsOS.roles.desktop) {
|
||||
|
||||
# ── Layer 1: disable power-profiles-daemon ───────────────────────────────
|
||||
# This removes the power-profile switcher from GNOME Settings entirely.
|
||||
services.power-profiles-daemon.enable = false;
|
||||
|
||||
# ── Layer 2: set cpufreq governor to performance ─────────────────────────
|
||||
# Pins all cores to max frequency. Works on Intel (intel_pstate) and AMD
|
||||
# (amd-pstate / acpi-cpufreq) alike.
|
||||
powerManagement.cpuFreqGovernor = "performance";
|
||||
|
||||
# ── Layer 3: enforce at boot via systemd oneshot ─────────────────────────
|
||||
# Belt-and-suspenders: ensures the governor is applied after every boot even
|
||||
# if the kernel module loads late.
|
||||
systemd.services.cpu-performance = {
|
||||
description = "Set CPU governor to performance on all cores";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "systemd-modules-load.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
found=0
|
||||
for gov in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||
if [ -w "$gov" ]; then
|
||||
echo performance > "$gov"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "cpu-performance: no writable cpufreq governors found (VM or unsupported hardware)" >&2
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# ── Legacy Cleanup ─────────────────────────────────────────────
|
||||
# Removes deprecated apps and folders from the old Sovran_Systems
|
||||
# repo that are no longer needed under staging_alpha.
|
||||
# This runs on every activation but is idempotent (no-ops if
|
||||
# the files are already gone).
|
||||
|
||||
system.activationScripts.cleanupLegacySovran = lib.stringAfter [ "users" ] ''
|
||||
echo "── Cleaning up legacy Sovran_Systems artifacts ──"
|
||||
|
||||
# Remove deprecated .desktop files
|
||||
for f in \
|
||||
/home/free/.local/share/applications/Sovran_SystemsOS_External_Backup.desktop \
|
||||
/home/free/.local/share/applications/Sovran_SystemsOS_Updater.desktop \
|
||||
/home/free/.local/share/applications/Sovran_SystemsOS_Resetter.desktop
|
||||
do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
echo " Removed: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove legacy Sovran_Systems folder (skip if it's a symlink)
|
||||
if [ -d /home/free/.Sovran_Systems ] && [ ! -L /home/free/.Sovran_Systems ]; then
|
||||
rm -rf /home/free/.Sovran_Systems
|
||||
echo " Removed: /home/free/.Sovran_Systems/"
|
||||
fi
|
||||
|
||||
echo "── Legacy cleanup complete ──"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# ── Server-local domain loopback overrides ────────────────────────────────────
|
||||
#
|
||||
# Some routers (especially newer ISP-provided devices) do not support NAT
|
||||
# loopback (hairpin NAT). When a request originates on this computer and
|
||||
# targets a public domain name that resolves to the router's WAN address, the
|
||||
# router may refuse to loop the connection back in — causing Nextcloud, WordPress
|
||||
# background jobs, and other server-side callbacks to fail even when the service
|
||||
# is fully operational from the internet.
|
||||
#
|
||||
# This module installs a one-shot systemd service,
|
||||
# ``sovran-hosts-update.service``, that reads the configured service domains
|
||||
# from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a
|
||||
# domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated
|
||||
# Sovran-managed block in ``/etc/hosts``.
|
||||
#
|
||||
# With those entries in place:
|
||||
# • Requests originating on this computer resolve the public domain name to
|
||||
# 127.0.0.1, reach Caddy directly, and never touch the router.
|
||||
# • Caddy still receives the correct public hostname via TLS SNI so virtual-
|
||||
# host routing and certificate validation continue to work.
|
||||
# • The Sovran Hub can verify Caddy reachability locally without needing NAT
|
||||
# loopback.
|
||||
#
|
||||
# Limitation: this does not help other devices on your home network (phones,
|
||||
# laptops). Those devices resolve domains via the router's DNS and still depend
|
||||
# on NAT loopback (or require manual router DNS overrides). For now, only
|
||||
# server-originated requests benefit from this override.
|
||||
#
|
||||
# On NixOS, /etc/hosts is normally a symlink into the Nix store and is
|
||||
# regenerated by the system activation script. The ``system.activationScripts``
|
||||
# hook below converts it to a writable file each time the system is activated
|
||||
# (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block.
|
||||
# The same script is also run by the ``sovran-hosts-update.service`` unit so
|
||||
# that the Hub can trigger it immediately after saving a domain without
|
||||
# requiring a full rebuild.
|
||||
|
||||
{
|
||||
# ── Helper script (stored in the Nix store, never reads /var/lib at eval) ──
|
||||
|
||||
environment.systemPackages = [ pkgs.coreutils ];
|
||||
|
||||
environment.etc."sovran-hosts-update.sh" = {
|
||||
mode = "0755";
|
||||
text = ''
|
||||
#!/bin/sh
|
||||
# Regenerate the Sovran-managed loopback block in /etc/hosts.
|
||||
# Safe to run multiple times — idempotent.
|
||||
set -eu
|
||||
|
||||
DOMAINS_DIR="/var/lib/domains"
|
||||
HOSTS_FILE="/etc/hosts"
|
||||
BEGIN_MARKER="# Sovran managed begin — server-local loopback overrides"
|
||||
END_MARKER="# Sovran managed end"
|
||||
|
||||
# ── Step 1: ensure /etc/hosts is a regular writable file ──────────────
|
||||
# On NixOS /etc/hosts starts as a symlink to the Nix store. We replace
|
||||
# it with a copy so we can append our block without touching the store.
|
||||
if [ -L "$HOSTS_FILE" ]; then
|
||||
TARGET=$(readlink -f "$HOSTS_FILE")
|
||||
cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp"
|
||||
mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE"
|
||||
chmod 644 "$HOSTS_FILE"
|
||||
fi
|
||||
|
||||
# ── Step 2: remove any existing Sovran block ──────────────────────────
|
||||
# Use a temp file so the operation is atomic.
|
||||
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
awk "
|
||||
/^$BEGIN_MARKER\$/ { skip=1; next }
|
||||
/^$END_MARKER\$/ { skip=0; next }
|
||||
!skip
|
||||
" "$HOSTS_FILE" > "$TMP"
|
||||
|
||||
# ── Step 3: collect valid configured service domains ──────────────────
|
||||
# NOTE: The hostname validation regex below must stay in sync with
|
||||
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
|
||||
ENTRIES=""
|
||||
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do
|
||||
FILE="$DOMAINS_DIR/$KEY"
|
||||
[ -f "$FILE" ] || continue
|
||||
# Read the domain value (strip all whitespace, limit to 253 chars)
|
||||
DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253)
|
||||
[ -z "$DOMAIN" ] && continue
|
||||
# Validate: must match a reasonable hostname pattern (no injection)
|
||||
if ! printf '%s' "$DOMAIN" | grep -qE \
|
||||
'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then
|
||||
echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2
|
||||
continue
|
||||
fi
|
||||
ENTRIES="$ENTRIES
|
||||
127.0.0.1 $DOMAIN
|
||||
::1 $DOMAIN"
|
||||
done
|
||||
|
||||
# ── Step 4: append the Sovran block if there are any entries ──────────
|
||||
if [ -n "$ENTRIES" ]; then
|
||||
printf '\n%s\n' "$BEGIN_MARKER" >> "$TMP"
|
||||
printf '%s\n' "# These entries route configured service domains to local Caddy." >> "$TMP"
|
||||
printf '%s\n' "# They are managed automatically — do not edit this block." >> "$TMP"
|
||||
printf '%s\n' "$ENTRIES" >> "$TMP"
|
||||
printf '%s\n' "$END_MARKER" >> "$TMP"
|
||||
fi
|
||||
|
||||
# ── Step 5: atomically replace /etc/hosts ─────────────────────────────
|
||||
mv "$TMP" "$HOSTS_FILE"
|
||||
chmod 644 "$HOSTS_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Systemd service ────────────────────────────────────────────────────────
|
||||
|
||||
systemd.services.sovran-hosts-update = {
|
||||
description = "Update /etc/hosts with Sovran server-local loopback overrides";
|
||||
documentation = [ "https://github.com/naturallaw777/sovran-systems" ];
|
||||
|
||||
# Run before Caddy so loopback entries are ready when it starts.
|
||||
before = [
|
||||
"caddy.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "/etc/sovran-hosts-update.sh";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Activation script (runs after every nixos-rebuild switch) ─────────────
|
||||
# This ensures the loopback block survives rebuilds that restore the /etc/hosts
|
||||
# symlink. The "users" and "etc" scripts must complete first.
|
||||
|
||||
system.activationScripts.sovranDomainLoopback = {
|
||||
text = ''
|
||||
if [ -x /etc/sovran-hosts-update.sh ] && [ -d /var/lib/domains ]; then
|
||||
/etc/sovran-hosts-update.sh || true
|
||||
fi
|
||||
'';
|
||||
deps = [ "etc" "users" ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# ── modules/core/no-sleep.nix ─────────────────────────────────────────────────
|
||||
# Prevents the machine from ever sleeping or suspending at the system level.
|
||||
#
|
||||
# Only applies to server_plus_desktop and node roles. Desktop-only installs
|
||||
# retain normal OS sleep/suspend behaviour.
|
||||
#
|
||||
# This operates at two layers below GNOME:
|
||||
# 1. systemd-logind — ignores all hardware power events (lid, suspend key, etc.)
|
||||
# 2. systemd targets — masks sleep/suspend/hibernate targets so nothing can
|
||||
# trigger them, not even `systemctl suspend` or D-Bus calls.
|
||||
#
|
||||
# This is intentional for a 24/7 server/node. The GNOME-layer power settings in
|
||||
# sovran_systemsos-desktop.nix remain in place as a belt-and-suspenders complement
|
||||
# for active user sessions.
|
||||
{ config, lib, ... }:
|
||||
|
||||
{
|
||||
config = lib.mkIf (!config.sovran_systemsOS.roles.desktop) {
|
||||
|
||||
# ── Layer 1: logind hardware event handling ──────────────────────────────
|
||||
services.logind.settings.Login = {
|
||||
HandleLidSwitch = "ignore";
|
||||
HandleLidSwitchDocked = "ignore";
|
||||
HandleLidSwitchExternalPower = "ignore";
|
||||
HandleSuspendKey = "ignore";
|
||||
HandleHibernateKey = "ignore";
|
||||
HandlePowerKey = "ignore";
|
||||
IdleAction = "ignore";
|
||||
IdleActionSec = 0;
|
||||
};
|
||||
|
||||
# ── Layer 2: mask systemd sleep targets ─────────────────────────────────
|
||||
# Nothing on the system can suspend/hibernate — not root, not GNOME, not D-Bus.
|
||||
systemd.targets.sleep.enable = false;
|
||||
systemd.targets.suspend.enable = false;
|
||||
systemd.targets.hibernate.enable = false;
|
||||
systemd.targets.hybrid-sleep.enable = false;
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.sovran_systemsOS.deploy;
|
||||
in
|
||||
|
||||
{
|
||||
options.sovran_systemsOS.deploy = {
|
||||
enable = lib.mkEnableOption "Remote deploy mode";
|
||||
|
||||
authorizedKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Deployer's SSH public key for root access";
|
||||
};
|
||||
|
||||
headscaleServer = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Headscale login server URL (e.g. https://hs.sovransystems.com). If set, Tailscale is used for post-install connectivity.";
|
||||
};
|
||||
|
||||
headscaleAuthKeyFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/secrets/headscale-authkey";
|
||||
description = "Path to file containing the Headscale pre-auth key for post-install enrollment";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
# ── Force SSH open on all interfaces ────────────────────────────────────
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
listenAddresses = lib.mkForce [
|
||||
{ addr = "0.0.0.0"; port = 22; }
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
settings = {
|
||||
PermitRootLogin = lib.mkForce "prohibit-password";
|
||||
PasswordAuthentication = lib.mkForce false;
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
# ── Inject deployer's SSH public key into root's authorized keys ─────────
|
||||
users.users.root.openssh.authorizedKeys.keys =
|
||||
lib.mkIf (cfg.authorizedKey != "") [ cfg.authorizedKey ];
|
||||
|
||||
# ── Force RDP on ─────────────────────────────────────────────────────────
|
||||
sovran_systemsOS.features.rdp = lib.mkForce true;
|
||||
|
||||
# ── Enable Fail2Ban for SSH protection ───────────────────────────────────
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
ignoreIP = [ "127.0.0.0/8" ];
|
||||
};
|
||||
|
||||
# ── Tailscale / Headscale VPN (only when headscaleServer is configured) ──
|
||||
services.tailscale = lib.mkIf (cfg.headscaleServer != "") {
|
||||
enable = true;
|
||||
};
|
||||
|
||||
environment.systemPackages = lib.mkIf (cfg.headscaleServer != "") [ pkgs.tailscale ];
|
||||
|
||||
systemd.services.deploy-tailscale-connect = lib.mkIf (cfg.headscaleServer != "") {
|
||||
description = "Connect to Headscale Tailnet for post-install remote access";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "tailscaled.service" ];
|
||||
wants = [ "network-online.target" "tailscaled.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
AUTH_KEY_FILE="${cfg.headscaleAuthKeyFile}"
|
||||
if [ ! -f "$AUTH_KEY_FILE" ]; then
|
||||
echo "Headscale auth key file not found: $AUTH_KEY_FILE — skipping Tailscale enrollment"
|
||||
exit 0
|
||||
fi
|
||||
AUTH_KEY=$(cat "$AUTH_KEY_FILE")
|
||||
[ -n "$AUTH_KEY" ] || { echo "Auth key file is empty, skipping"; exit 0; }
|
||||
|
||||
HOSTNAME_SUFFIX=$(hostname | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/-\{2,\}/-/g; s/^-//; s/-$//')
|
||||
HOSTNAME="sovran-$HOSTNAME_SUFFIX"
|
||||
|
||||
echo "Joining Tailnet via ${cfg.headscaleServer} as $HOSTNAME..."
|
||||
${pkgs.tailscale}/bin/tailscale up \
|
||||
--login-server="${cfg.headscaleServer}" \
|
||||
--authkey="$AUTH_KEY" \
|
||||
--hostname="$HOSTNAME"
|
||||
|
||||
echo "Tailscale IP: $(${pkgs.tailscale}/bin/tailscale ip -4 2>/dev/null || echo 'pending')"
|
||||
'';
|
||||
path = [ pkgs.tailscale pkgs.coreutils ];
|
||||
};
|
||||
|
||||
# ── Safety auto-expiry service ────────────────────────────────────────────
|
||||
systemd.services.deploy-auto-expire = {
|
||||
description = "Auto-expire remote deploy mode after 48 hours";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
};
|
||||
script = ''
|
||||
# 48 hours = 172800 seconds
|
||||
sleep $((48 * 60 * 60))
|
||||
systemctl stop deploy-tailscale-connect || true
|
||||
mkdir -p /etc/sovran
|
||||
echo "expired" > /etc/sovran/deploy-mode
|
||||
'';
|
||||
path = [ pkgs.coreutils ];
|
||||
};
|
||||
|
||||
# ── Deploy-mode indicator file ────────────────────────────────────────────
|
||||
environment.etc."sovran/deploy-mode".text = "active";
|
||||
};
|
||||
}
|
||||
@@ -3,27 +3,44 @@
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
|
||||
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
|
||||
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
|
||||
# Only systems can evaluate without enabling any Bitcoin services.
|
||||
{
|
||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||
}
|
||||
|
||||
# ── Server+Desktop Role (default) ─────────────────────────
|
||||
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
|
||||
})
|
||||
|
||||
# ── Desktop Only Role ─────────────────────────────────────
|
||||
(lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||
services.desktopManager.gnome.enable = true;
|
||||
|
||||
# Force all server/node services and features off so they cannot be
|
||||
# accidentally enabled via custom.nix or option defaults on Desktop Only.
|
||||
sovran_systemsOS.services = {
|
||||
synapse = lib.mkDefault false;
|
||||
bitcoin = lib.mkDefault false;
|
||||
vaultwarden = lib.mkDefault false;
|
||||
wordpress = lib.mkDefault false;
|
||||
nextcloud = lib.mkDefault false;
|
||||
synapse = lib.mkForce false;
|
||||
bitcoin = lib.mkForce false;
|
||||
vaultwarden = lib.mkForce false;
|
||||
wordpress = lib.mkForce false;
|
||||
nextcloud = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
sovran_systemsOS.features = {
|
||||
haven = lib.mkForce false;
|
||||
mempool = lib.mkForce false;
|
||||
element-calling = lib.mkForce false;
|
||||
bitcoin-core = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
|
||||
})
|
||||
|
||||
# ── Bitcoin Node Only Role ────────────────────────────────
|
||||
# Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
|
||||
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy
|
||||
(lib.mkIf config.sovran_systemsOS.roles.node {
|
||||
sovran_systemsOS.services = {
|
||||
bitcoin = lib.mkDefault true;
|
||||
@@ -35,7 +52,6 @@
|
||||
|
||||
sovran_systemsOS.features = {
|
||||
mempool = lib.mkDefault true;
|
||||
bip110 = lib.mkDefault true;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
|
||||
@@ -43,23 +43,44 @@
|
||||
# ── Features (default OFF — user can enable in custom.nix) ──
|
||||
features = {
|
||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
|
||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
|
||||
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
|
||||
# default node. This option is retained ONLY so that existing machines with
|
||||
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
|
||||
# custom.nix continue to evaluate. It has no effect and will be removed in a
|
||||
# future release once the Hub has cleaned up old custom.nix files.
|
||||
bip110 = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
internal = true;
|
||||
visible = false;
|
||||
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||
web = {
|
||||
btcpayserver = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
default = false;
|
||||
description = "Expose BTCPay Server via Caddy";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Caddy customisation ───────────────────────────────────
|
||||
caddy = {
|
||||
extraVirtualHosts = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
description = "Additional raw Caddyfile blocks appended to the generated Caddy config. Use this in custom.nix to add custom domains and reverse proxies.";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Domain setup registry ─────────────────────────────────
|
||||
domainRequirements = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
@@ -80,4 +101,15 @@
|
||||
description = "Nostr public key (npub1...) for Haven relay";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
|
||||
warnings = [
|
||||
''
|
||||
sovran_systemsOS.features.bip110 is deprecated and has no effect:
|
||||
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
|
||||
You can safely remove the `sovran_systemsOS.features.bip110` line from
|
||||
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
|
||||
''
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ let
|
||||
[
|
||||
{ name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "passwords"; enabled = true; category = "infrastructure"; credentials = [
|
||||
{ label = "Free Account — Username"; value = "free"; }
|
||||
{ label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; }
|
||||
{ label = "Root Password"; file = "/var/lib/secrets/root-password"; }
|
||||
{ label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; }
|
||||
{ label = "Free Account / Hub Login — Password"; file = "/var/lib/secrets/free-password"; }
|
||||
{ label = "Administrator (root) Password"; file = "/var/lib/secrets/root-password"; }
|
||||
{ label = "SSH Passphrase — use via: ssh root@localhost"; file = "/var/lib/secrets/ssh-passphrase"; }
|
||||
]; }
|
||||
]
|
||||
# ── Infrastructure — Caddy + Tor (NOT desktop-only) ────────
|
||||
@@ -24,52 +24,47 @@ let
|
||||
{ label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; }
|
||||
{ label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; }
|
||||
{ label = "Address"; file = "/var/lib/secrets/internal-ip"; suffix = ":3389"; }
|
||||
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above\n5. Connect — you will see your desktop remotely"; }
|
||||
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Base (node implementations) ────────────────────
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; }
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; }
|
||||
{ label = "Port"; value = "50001"; }
|
||||
]; }
|
||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
||||
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Access"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
||||
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
|
||||
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
||||
]; }
|
||||
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.web.btcpayserver; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; }
|
||||
{ label = "Note"; value = "Create your admin account on first visit"; }
|
||||
]; }
|
||||
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; }
|
||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; }
|
||||
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above"; }
|
||||
]; }
|
||||
{ name = "Sparrow Auto-Connect"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Bisq Auto-Connect"; unit = "bisq-autoconnect.service"; type = "system"; icon = "bisq"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Node"; value = "127.0.0.1:8333 (Bitcoin Core)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
||||
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
||||
]; }
|
||||
]
|
||||
# ── Communication (server+desktop only) ────────────────────
|
||||
@@ -140,16 +135,27 @@ let
|
||||
RC=0
|
||||
|
||||
echo "── Step 1/3: nix flake update ────────────────────"
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
echo "[ERROR] nix flake update failed"
|
||||
RC=1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "── Step 2/3: nixos-rebuild switch ──────────────────"
|
||||
if ! nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
BOOT_RC=$?
|
||||
echo "$BOOT_OUT"
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
fi
|
||||
echo ""
|
||||
@@ -165,9 +171,10 @@ let
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Update completed successfully"
|
||||
echo " ✓ Update staged successfully"
|
||||
echo " Reboot required to activate the new system"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Update failed — see errors above"
|
||||
@@ -195,12 +202,34 @@ let
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Rebuild completed successfully"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
echo "FAILED" > "$STATUS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
@@ -211,9 +240,28 @@ let
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
||||
# deterministic and GNOME Shell can match the window to the .desktop
|
||||
# entry (fixes generic gear icon appearing in the dock).
|
||||
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
||||
mkdir -p "$HUB_DATA"
|
||||
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
|
||||
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||
brave --app=http://localhost:8937/auto-login \
|
||||
--class=sovran-hub \
|
||||
--user-data-dir="$HUB_DATA" \
|
||||
--password-store=basic \
|
||||
--disable-gpu \
|
||||
--disable-features=WebRtcPipeWireCapturer \
|
||||
--ozone-platform=wayland
|
||||
'';
|
||||
|
||||
# ── Hub auto-launch wrapper script ────────────────────────────────
|
||||
hub-autolaunch-script = pkgs.writeShellScript "sovran-hub-autolaunch.sh" ''
|
||||
export PATH="${lib.makeBinPath [ pkgs.curl pkgs.xdg-utils ]}:$PATH"
|
||||
export PATH="${lib.makeBinPath [ pkgs.curl pkgs.coreutils ]}:$PATH"
|
||||
|
||||
DISABLE_FLAG="/var/lib/sovran/hub-autolaunch-disabled"
|
||||
BOOT_FLAG="/run/sovran-hub-autolaunch-done"
|
||||
@@ -232,7 +280,7 @@ let
|
||||
sleep 1
|
||||
done
|
||||
|
||||
xdg-open http://localhost:8937
|
||||
${hub-brave-wrapper}
|
||||
'';
|
||||
|
||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||
@@ -242,6 +290,8 @@ let
|
||||
|
||||
src = ../../app;
|
||||
|
||||
nativeBuildInputs = [ pkgs.librsvg ];
|
||||
|
||||
propagatedBuildInputs = with pkgs.python3Packages; [
|
||||
fastapi
|
||||
uvicorn
|
||||
@@ -263,7 +313,12 @@ let
|
||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||
|
||||
install -d $out/share/icons/hicolor/scalable/apps
|
||||
cp sovran_systemsos_web/static/logo-light.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg
|
||||
cp sovran_systemsos_web/static/sovran-hub-icon.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg
|
||||
|
||||
for size in 48 128 256 512; do
|
||||
install -d $out/share/icons/hicolor/''${size}x''${size}/apps
|
||||
rsvg-convert -w ''${size} -h ''${size} sovran_systemsos_web/static/sovran-hub-icon.svg -o $out/share/icons/hicolor/''${size}x''${size}/apps/sovran-hub.png
|
||||
done
|
||||
|
||||
install -d $out/share/applications
|
||||
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
|
||||
@@ -271,11 +326,13 @@ let
|
||||
Type=Application
|
||||
Name=Sovran Hub
|
||||
Comment=Open Sovran_SystemsOS Hub dashboard
|
||||
Exec=xdg-open http://localhost:8937
|
||||
Exec=${hub-brave-wrapper}
|
||||
Icon=sovran-hub
|
||||
Terminal=false
|
||||
Categories=System;
|
||||
StartupNotify=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=brave-localhost__auto-login-Default
|
||||
X-GNOME-SingleWindow=true
|
||||
DESKTOP
|
||||
|
||||
install -d $out/bin
|
||||
@@ -312,21 +369,32 @@ in
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
conflicts = [ "sovran-hub-reboot.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${sovran-hub-web}/bin/sovran-hub-web";
|
||||
Restart = "on-failure";
|
||||
RestartPreventExitStatus = "SIGTERM";
|
||||
RestartSec = "5s";
|
||||
User = "root";
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
};
|
||||
|
||||
path = [ pkgs.qrencode ] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
|
||||
path = [
|
||||
pkgs.qrencode
|
||||
pkgs.curl
|
||||
pkgs.iproute2
|
||||
pkgs.nftables
|
||||
pkgs.iptables
|
||||
pkgs.hostname
|
||||
] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
|
||||
};
|
||||
|
||||
systemd.services.sovran-hub-update = {
|
||||
description = "Sovran_SystemsOS System Update";
|
||||
restartIfChanged = false; # Don't let nixos-rebuild kill an in-flight update
|
||||
stopIfChanged = false; # Don't stop it during activation either
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${update-script}";
|
||||
@@ -335,15 +403,25 @@ in
|
||||
|
||||
systemd.services.sovran-hub-rebuild = {
|
||||
description = "Sovran_SystemsOS System Rebuild";
|
||||
restartIfChanged = false; # Don't let nixos-rebuild kill an in-flight rebuild
|
||||
stopIfChanged = false; # Don't stop it during activation either
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${rebuild-script}";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.sovran-hub-reboot = {
|
||||
description = "Sovran_SystemsOS System Reboot";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "/run/current-system/sw/bin/systemctl --force reboot";
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ sovran-hub-web ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 3051 8937 60847 ];
|
||||
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
|
||||
|
||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# ── sovran-provisioner.nix ────────────────────────────────────────────────────
|
||||
# NixOS module for the Sovran Systems VPS provisioning server.
|
||||
#
|
||||
# Deploys:
|
||||
# - Headscale (coordination server, listening on 127.0.0.1:8080)
|
||||
# - Python Flask provisioning API (port 9090)
|
||||
# - Caddy reverse proxy (80/443 with automatic TLS)
|
||||
# - Bootstrap service (creates Headscale users + enrollment token on first boot)
|
||||
#
|
||||
# Headscale 0.28.0 compatible — uses numeric user IDs (-u <id>) throughout.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
let
|
||||
cfg = config.sovranProvisioner;
|
||||
|
||||
# ── Python Flask provisioner script ────────────────────────────────────────
|
||||
provisionerScript = pkgs.writeText "sovran-provisioner.py" ''
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sovran Systems provisioning API — Headscale 0.28.0 compatible.
|
||||
Endpoints:
|
||||
POST /register — register a new machine and return a Headscale pre-auth key
|
||||
GET /machines — list registered machines (requires Bearer token)
|
||||
GET /health — liveness check
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request, jsonify, abort
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.environ.get("PROVISIONER_DATA_DIR", "/var/lib/sovran-provisioner"))
|
||||
TOKEN_FILE = DATA_DIR / "enroll-token"
|
||||
MACHINES_FILE = DATA_DIR / "machines.json"
|
||||
HEADSCALE_USER = os.environ.get("HEADSCALE_USER", "sovran-deploy")
|
||||
KEY_EXPIRY = os.environ.get("KEY_EXPIRY", "1h")
|
||||
RATE_LIMIT_MAX = int(os.environ.get("RATE_LIMIT_MAX", "10"))
|
||||
RATE_LIMIT_WIN = int(os.environ.get("RATE_LIMIT_WINDOW", "60"))
|
||||
|
||||
# ── Simple in-memory rate limiter ─────────────────────────────────────────
|
||||
_rate_buckets: dict = defaultdict(list)
|
||||
|
||||
def _rate_limit_check(key: str) -> bool:
|
||||
"""Return True if the request is allowed, False if rate-limited."""
|
||||
now = time.monotonic()
|
||||
bucket = _rate_buckets[key]
|
||||
# Purge entries outside the window
|
||||
_rate_buckets[key] = [t for t in bucket if now - t < RATE_LIMIT_WIN]
|
||||
if len(_rate_buckets[key]) >= RATE_LIMIT_MAX:
|
||||
return False
|
||||
_rate_buckets[key].append(now)
|
||||
return True
|
||||
|
||||
# ── Helper: read enrollment token ─────────────────────────────────────────
|
||||
def _get_token() -> str:
|
||||
try:
|
||||
return TOKEN_FILE.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
# ── Helper: require Bearer token ──────────────────────────────────────────
|
||||
def require_token(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
abort(401)
|
||||
token = auth[len("Bearer "):].strip()
|
||||
expected = _get_token()
|
||||
if not expected or token != expected:
|
||||
abort(401)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
# ── Helper: persist machine record ────────────────────────────────────────
|
||||
def _save_machine(hostname: str, mac: str, tailscale_ip: str = ""):
|
||||
machines = _load_machines()
|
||||
machines[mac] = {
|
||||
"hostname": hostname,
|
||||
"mac": mac,
|
||||
"registered_at": time.time(),
|
||||
"tailscale_ip": tailscale_ip,
|
||||
}
|
||||
MACHINES_FILE.write_text(json.dumps(machines, indent=2))
|
||||
|
||||
def _load_machines() -> dict:
|
||||
try:
|
||||
return json.loads(MACHINES_FILE.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
# ── Headscale helpers (0.28.0 compatible) ────────────────────────────────
|
||||
|
||||
def get_user_id(username: str):
|
||||
"""Look up numeric user ID from username for Headscale 0.28.0."""
|
||||
result = subprocess.run(
|
||||
["headscale", "users", "list", "-o", "json"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
app.logger.error("headscale users list failed: %s", result.stderr)
|
||||
return None
|
||||
try:
|
||||
users = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
app.logger.error("headscale users list returned invalid JSON: %s", result.stdout)
|
||||
return None
|
||||
for user in users:
|
||||
if user.get("name") == username:
|
||||
return user.get("id")
|
||||
return None
|
||||
|
||||
def create_preauthkey(user_id, expiry: str = "1h") -> str | None:
|
||||
"""Create a pre-auth key using the numeric user ID (Headscale 0.28.0)."""
|
||||
result = subprocess.run(
|
||||
["headscale", "preauthkeys", "create",
|
||||
"-u", str(user_id),
|
||||
"-e", expiry,
|
||||
"-o", "json"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
app.logger.error("headscale preauthkeys create failed: %s", result.stderr)
|
||||
return None
|
||||
try:
|
||||
key_data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
app.logger.error("preauthkeys create returned invalid JSON: %s", result.stdout)
|
||||
return None
|
||||
return key_data.get("key")
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/register", methods=["POST"])
|
||||
@require_token
|
||||
def register():
|
||||
# Rate-limit by source IP
|
||||
client_ip = request.remote_addr or "unknown"
|
||||
if not _rate_limit_check(client_ip):
|
||||
return jsonify({"error": "rate limit exceeded"}), 429
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
hostname = data.get("hostname", "").strip()
|
||||
mac = data.get("mac", "").strip()
|
||||
if not hostname or not mac:
|
||||
return jsonify({"error": "hostname and mac are required"}), 400
|
||||
|
||||
# Look up the numeric user ID (Headscale 0.28.0 requires -u <id>)
|
||||
user_id = get_user_id(HEADSCALE_USER)
|
||||
if user_id is None:
|
||||
app.logger.error("Headscale user '%s' not found", HEADSCALE_USER)
|
||||
return jsonify({"error": "provisioning user not found on Headscale server"}), 500
|
||||
|
||||
# Create a single-use pre-auth key
|
||||
key = create_preauthkey(user_id, expiry=KEY_EXPIRY)
|
||||
if key is None:
|
||||
return jsonify({"error": "failed to create pre-auth key"}), 500
|
||||
|
||||
# Persist the registration record
|
||||
_save_machine(hostname, mac)
|
||||
|
||||
login_server = os.environ.get("HEADSCALE_URL", "")
|
||||
return jsonify({
|
||||
"headscale_key": key,
|
||||
"login_server": login_server,
|
||||
"hostname": hostname,
|
||||
})
|
||||
|
||||
@app.route("/machines")
|
||||
@require_token
|
||||
def machines():
|
||||
return jsonify(list(_load_machines().values()))
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.run(host="127.0.0.1", port=9090)
|
||||
'';
|
||||
|
||||
# ── Headscale YAML config ──────────────────────────────────────────────────
|
||||
headscaleConfig = pkgs.writeText "headscale.yaml" ''
|
||||
server_url: https://${cfg.headscaleDomain}
|
||||
listen_addr: 127.0.0.1:8080
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
|
||||
# Logging
|
||||
log:
|
||||
level: info
|
||||
|
||||
# Database
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: /var/lib/headscale/db.sqlite
|
||||
|
||||
# DERP (relay/STUN)
|
||||
derp:
|
||||
server:
|
||||
enabled: false
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
auto_update_enabled: true
|
||||
update_frequency: 24h
|
||||
|
||||
# Disable magic DNS by default (clients opt in)
|
||||
dns:
|
||||
magic_dns: false
|
||||
base_domain: sovran.internal
|
||||
|
||||
# Node expiry
|
||||
node_update_check_interval: 10s
|
||||
'';
|
||||
|
||||
in
|
||||
|
||||
{
|
||||
# ── Module options ─────────────────────────────────────────────────────────
|
||||
options.sovranProvisioner = {
|
||||
enable = lib.mkEnableOption "Sovran Systems provisioning server (Headscale + Flask API + Caddy)";
|
||||
|
||||
domain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Public FQDN for the provisioning API (e.g. prov.yourdomain.com)";
|
||||
};
|
||||
|
||||
headscaleDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Public FQDN for the Headscale coordination server (e.g. hs.yourdomain.com)";
|
||||
};
|
||||
|
||||
headscaleUser = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "sovran-deploy";
|
||||
description = "Headscale user namespace for deployed machines";
|
||||
};
|
||||
|
||||
adminUser = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "admin";
|
||||
description = "Headscale user namespace for admin workstations";
|
||||
};
|
||||
|
||||
keyExpiry = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "1h";
|
||||
description = "Lifetime of generated pre-auth keys (e.g. 1h, 2h, 24h)";
|
||||
};
|
||||
|
||||
rateLimitMax = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 10;
|
||||
description = "Maximum number of /register calls per rateLimitWindow seconds per IP";
|
||||
};
|
||||
|
||||
rateLimitWindow = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 60;
|
||||
description = "Rate-limit sliding window in seconds";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Module implementation ──────────────────────────────────────────────────
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
# ── Headscale ─────────────────────────────────────────────────────────────
|
||||
services.headscale = {
|
||||
enable = true;
|
||||
address = "127.0.0.1";
|
||||
port = 8080;
|
||||
settings = {
|
||||
server_url = "https://${cfg.headscaleDomain}";
|
||||
listen_addr = "127.0.0.1:8080";
|
||||
database = {
|
||||
type = "sqlite";
|
||||
sqlite = { path = "/var/lib/headscale/db.sqlite"; };
|
||||
};
|
||||
dns = {
|
||||
magic_dns = false;
|
||||
base_domain = "sovran.internal";
|
||||
};
|
||||
derp = {
|
||||
server.enabled = false;
|
||||
urls = [ "https://controlplane.tailscale.com/derpmap/default" ];
|
||||
auto_update_enabled = true;
|
||||
update_frequency = "24h";
|
||||
};
|
||||
log.level = "info";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Python / Flask dependencies ────────────────────────────────────────────
|
||||
environment.systemPackages = [
|
||||
pkgs.headscale
|
||||
(pkgs.python3.withPackages (ps: [ ps.flask ]))
|
||||
];
|
||||
|
||||
# ── Provisioner systemd service ────────────────────────────────────────────
|
||||
systemd.services.sovran-provisioner = {
|
||||
description = "Sovran provisioning API";
|
||||
after = [ "network-online.target" "headscale.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
environment = {
|
||||
PROVISIONER_DATA_DIR = "/var/lib/sovran-provisioner";
|
||||
HEADSCALE_USER = cfg.headscaleUser;
|
||||
KEY_EXPIRY = cfg.keyExpiry;
|
||||
RATE_LIMIT_MAX = toString cfg.rateLimitMax;
|
||||
RATE_LIMIT_WINDOW = toString cfg.rateLimitWindow;
|
||||
HEADSCALE_URL = "https://${cfg.headscaleDomain}";
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
DynamicUser = false;
|
||||
User = "sovran-provisioner";
|
||||
Group = "sovran-provisioner";
|
||||
StateDirectory = "sovran-provisioner";
|
||||
RuntimeDirectory = "sovran-provisioner";
|
||||
ExecStart = "${pkgs.python3.withPackages (ps: [ ps.flask ])}/bin/python3 ${provisionerScript}";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Dedicated system user for the provisioner ──────────────────────────────
|
||||
users.users.sovran-provisioner = {
|
||||
isSystemUser = true;
|
||||
group = "sovran-provisioner";
|
||||
description = "Sovran provisioning API service user";
|
||||
};
|
||||
users.groups.sovran-provisioner = {};
|
||||
|
||||
# Allow the provisioner user to call headscale CLI
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "sovran-provisioner" ];
|
||||
commands = [{
|
||||
command = "${pkgs.headscale}/bin/headscale";
|
||||
options = [ "NOPASSWD" ];
|
||||
}];
|
||||
}];
|
||||
|
||||
# ── Bootstrap service (first-boot: create Headscale users + enroll token) ──
|
||||
systemd.services.sovran-provisioner-bootstrap = {
|
||||
description = "Bootstrap Headscale users and enrollment token";
|
||||
after = [ "headscale.service" ];
|
||||
wants = [ "headscale.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
StateDirectory = "sovran-provisioner";
|
||||
};
|
||||
path = [ pkgs.headscale pkgs.coreutils pkgs.openssl ];
|
||||
script = ''
|
||||
DATA_DIR="/var/lib/sovran-provisioner"
|
||||
TOKEN_FILE="$DATA_DIR/enroll-token"
|
||||
STAMP="$DATA_DIR/.bootstrap-done"
|
||||
|
||||
# Idempotent — only run once
|
||||
[ -f "$STAMP" ] && exit 0
|
||||
|
||||
# Wait for headscale socket to be ready
|
||||
for i in $(seq 1 30); do
|
||||
headscale users list -o json >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Create headscale users if they don't exist
|
||||
headscale users list -o json | grep -q '"name":"${cfg.headscaleUser}"' \
|
||||
|| headscale users create ${cfg.headscaleUser}
|
||||
|
||||
headscale users list -o json | grep -q '"name":"${cfg.adminUser}"' \
|
||||
|| headscale users create ${cfg.adminUser}
|
||||
|
||||
# Generate enrollment token if not already present
|
||||
if [ ! -f "$TOKEN_FILE" ] || [ ! -s "$TOKEN_FILE" ]; then
|
||||
openssl rand -hex 32 > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
fi
|
||||
|
||||
touch "$STAMP"
|
||||
echo "Bootstrap complete."
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Caddy reverse proxy ────────────────────────────────────────────────────
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
virtualHosts."${cfg.headscaleDomain}" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
'';
|
||||
};
|
||||
virtualHosts."${cfg.domain}" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy 127.0.0.1:9090
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# ── Firewall ────────────────────────────────────────────────────────────────
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [ 80 443 ];
|
||||
allowedUDPPorts = [ 3478 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -2,25 +2,196 @@
|
||||
|
||||
let
|
||||
|
||||
wallpaperSrc = ../../assets/wallpapers;
|
||||
|
||||
customWallpaper = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "sovran-systemsos-wallpaper";
|
||||
version = "1.0";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS_iso/raw/branch/main/post-install-scripts/Wallpaper_Dark_Wide.png";
|
||||
sha256 = "0609gy0vp92fywl7pcr4y3mg05ca6pwxsnlsax14jd371fj4y7fn";
|
||||
};
|
||||
dontUnpack = true;
|
||||
version = "2.0";
|
||||
src = wallpaperSrc;
|
||||
nativeBuildInputs = [ pkgs.librsvg ];
|
||||
installPhase = ''
|
||||
mkdir -p $out/share/backgrounds/sovran
|
||||
cp $src $out/share/backgrounds/sovran/Wallpaper_Dark_Wide.png
|
||||
'';
|
||||
|
||||
rsvg-convert -w 3440 -h 1440 \
|
||||
$src/sovran-wallpaper-12-ultrawide-3440x1440.svg \
|
||||
-o $out/share/backgrounds/sovran/sovran-ultrawide.png
|
||||
'';
|
||||
};
|
||||
|
||||
sovranThemeInit = pkgs.writeShellScriptBin "sovran-theme-init" ''
|
||||
STAMP="$HOME/.config/sovran-theme-applied"
|
||||
USER_DB="$HOME/.config/dconf/user"
|
||||
|
||||
# ── Always apply wallpaper on version change ──
|
||||
WALLPAPER_VERSION="${customWallpaper.version}"
|
||||
WALLPAPER_STAMP="$HOME/.config/sovran-wallpaper-version"
|
||||
|
||||
BG_DIR="/run/current-system/sw/share/backgrounds/sovran"
|
||||
ULTRAWIDE="$BG_DIR/sovran-ultrawide.png"
|
||||
|
||||
CURRENT_WALLPAPER_VERSION=""
|
||||
if [ -r "$WALLPAPER_STAMP" ]; then
|
||||
read -r CURRENT_WALLPAPER_VERSION < "$WALLPAPER_STAMP"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_WALLPAPER_VERSION" != "$WALLPAPER_VERSION" ]; then
|
||||
if [ -f "$ULTRAWIDE" ]; then
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri "'file://$ULTRAWIDE'"
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$ULTRAWIDE'"
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-options "'zoom'"
|
||||
mkdir -p "$(dirname "$WALLPAPER_STAMP")"
|
||||
echo "$WALLPAPER_VERSION" > "$WALLPAPER_STAMP"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Already applied — skip
|
||||
if [ -f "$STAMP" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Existing machine updating — user already has their own settings, don't overwrite
|
||||
if [ -f "$USER_DB" ]; then
|
||||
mkdir -p "$HOME/.config"
|
||||
touch "$STAMP"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fresh install — no user-db exists yet, apply full Sovran theme below
|
||||
|
||||
mkdir -p "$HOME/.config"
|
||||
cat > "$HOME/.config/mimeapps.list" << EOF
|
||||
[Default Applications]
|
||||
text/html=brave-browser.desktop
|
||||
x-scheme-handler/http=brave-browser.desktop
|
||||
x-scheme-handler/https=brave-browser.desktop
|
||||
x-scheme-handler/about=brave-browser.desktop
|
||||
x-scheme-handler/unknown=brave-browser.desktop
|
||||
EOF
|
||||
|
||||
${pkgs.dconf}/bin/dconf load / << EOF
|
||||
[org/gnome/desktop/interface]
|
||||
color-scheme='prefer-dark'
|
||||
enable-animations=true
|
||||
icon-theme='Papirus-Dark'
|
||||
|
||||
[org/gnome/settings-daemon/plugins/power]
|
||||
sleep-inactive-ac-type='nothing'
|
||||
sleep-inactive-ac-timeout=0
|
||||
sleep-inactive-battery-type='nothing'
|
||||
sleep-inactive-battery-timeout=0
|
||||
idle-dim=false
|
||||
ambient-enabled=false
|
||||
power-button-action='nothing'
|
||||
|
||||
[org/gnome/desktop/session]
|
||||
idle-delay=uint32 0
|
||||
|
||||
[org/gnome/desktop/screensaver]
|
||||
lock-enabled=false
|
||||
idle-activation-enabled=false
|
||||
|
||||
[org/gnome/mutter]
|
||||
edge-tiling=false
|
||||
|
||||
[org/gnome/nautilus/icon-view]
|
||||
default-zoom-level='large'
|
||||
|
||||
[org/gnome/nautilus/preferences]
|
||||
default-folder-viewer='icon-view'
|
||||
migrated-gtk-settings=true
|
||||
search-filter-time-type='last_modified'
|
||||
|
||||
[org/gnome/shell]
|
||||
disabled-extensions=['just-perfection-desktop@just-perfection']
|
||||
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
||||
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||
welcome-dialog-last-shown-version='48.4'
|
||||
|
||||
[org/gnome/desktop/app-folders]
|
||||
folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', 'System']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Browsers]
|
||||
name='Browsers'
|
||||
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Office]
|
||||
name='Office'
|
||||
apps=['libreoffice-writer.desktop', 'libreoffice-calc.desktop', 'libreoffice-impress.desktop', 'libreoffice-draw.desktop', 'libreoffice-base.desktop', 'libreoffice-math.desktop', 'libreoffice-startcenter.desktop', 'org.gnome.TextEditor.desktop', 'org.gnome.gedit.desktop', 'org.gnome.Calculator.desktop', 'org.gnome.Calendar.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Evince.desktop', 'onlyoffice-desktopeditors.desktop', 'simple-scan.desktop', 'system-config-printer.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Terminal]
|
||||
name='Terminal'
|
||||
apps=['org.gnome.Terminal.desktop', 'org.gnome.tweaks.desktop', 'gparted.desktop', 'htop.desktop', 'btop.desktop', 'ranger.desktop', 'org.gnome.Console.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Chat]
|
||||
name='Chat'
|
||||
apps=['element-desktop.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Bitcoin]
|
||||
name='Bitcoin'
|
||||
apps=['sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Media]
|
||||
name='Media'
|
||||
apps=['org.gnome.Loupe.desktop', 'org.gnome.Totem.desktop', 'org.gnome.Snapshot.desktop', 'org.gnome.Weather.desktop', 'org.gnome.Maps.desktop', 'org.gnome.Clocks.desktop', 'org.gnome.Music.desktop', 'org.gnome.Characters.desktop', 'org.gnome.font-viewer.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/System]
|
||||
name='System'
|
||||
apps=['org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'org.gnome.Software.desktop', 'sovran-hub.desktop', 'bitwarden.desktop', 'org.gnome.DiskUtility.desktop', 'org.gnome.SystemMonitor.desktop', 'org.gnome.Logs.desktop', 'org.gnome.Connections.desktop', 'org.gnome.baobab.desktop', 'zenity.desktop']
|
||||
|
||||
[org/gnome/shell/extensions/dash-to-dock]
|
||||
background-color='rgb(0,0,0)'
|
||||
background-opacity=0.50000000000000001
|
||||
custom-background-color=true
|
||||
dash-max-icon-size=47
|
||||
dock-position='BOTTOM'
|
||||
height-fraction=0.90000000000000002
|
||||
preferred-monitor=-2
|
||||
preferred-monitor-by-connector='Virtual-1'
|
||||
show-trash=false
|
||||
transparency-mode='FIXED'
|
||||
|
||||
[org/gnome/shell/extensions/date-menu-formatter]
|
||||
font-size=12
|
||||
pattern='EEEE, MMM d h:mm a'
|
||||
text-align='center'
|
||||
update-level=1
|
||||
|
||||
[org/gnome/shell/extensions/just-perfection]
|
||||
support-notifier-showed-version=34
|
||||
support-notifier-type=0
|
||||
|
||||
[org/gnome/shell/extensions/pop-shell]
|
||||
tile-by-default=true
|
||||
|
||||
[org/gnome/shell/extensions/vitals]
|
||||
hot-sensors=['_storage_free_', '_processor_usage_', '_memory_usage_']
|
||||
|
||||
[org/gnome/software]
|
||||
first-run=false
|
||||
|
||||
[org/gtk/gtk4/settings/color-chooser]
|
||||
selected-color=(true, 0.0, 0.0, 0.0, 1.0)
|
||||
EOF
|
||||
|
||||
mkdir -p "$HOME/.config"
|
||||
touch "$STAMP"
|
||||
'';
|
||||
|
||||
in
|
||||
|
||||
{
|
||||
|
||||
environment.systemPackages = [ customWallpaper ];
|
||||
environment.systemPackages = [ customWallpaper sovranThemeInit ];
|
||||
|
||||
environment.etc."xdg/autostart/sovran-theme-init.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Sovran Theme Init
|
||||
Exec=${sovranThemeInit}/bin/sovran-theme-init
|
||||
X-GNOME-Autostart-enabled=true
|
||||
X-GNOME-Autostart-Phase=Application
|
||||
NoDisplay=true
|
||||
'';
|
||||
|
||||
programs.dconf.enable = true;
|
||||
|
||||
@@ -29,8 +200,8 @@ in
|
||||
settings = {
|
||||
|
||||
"org/gnome/desktop/background" = {
|
||||
picture-uri = "file:///run/current-system/sw/share/backgrounds/sovran/Wallpaper_Dark_Wide.png";
|
||||
picture-uri-dark = "file:///run/current-system/sw/share/backgrounds/sovran/Wallpaper_Dark_Wide.png";
|
||||
picture-uri = "file:///run/current-system/sw/share/backgrounds/sovran/sovran-ultrawide.png";
|
||||
picture-uri-dark = "file:///run/current-system/sw/share/backgrounds/sovran/sovran-ultrawide.png";
|
||||
picture-options = "zoom";
|
||||
primary-color = "#000000";
|
||||
secondary-color = "#000000";
|
||||
@@ -47,6 +218,25 @@ in
|
||||
icon-theme = "Papirus-Dark";
|
||||
};
|
||||
|
||||
"org/gnome/settings-daemon/plugins/power" = {
|
||||
sleep-inactive-ac-type = "nothing";
|
||||
sleep-inactive-ac-timeout = lib.gvariant.mkInt32 0;
|
||||
sleep-inactive-battery-type = "nothing";
|
||||
sleep-inactive-battery-timeout = lib.gvariant.mkInt32 0;
|
||||
idle-dim = false;
|
||||
ambient-enabled = false;
|
||||
power-button-action = "nothing";
|
||||
};
|
||||
|
||||
"org/gnome/desktop/session" = {
|
||||
idle-delay = lib.gvariant.mkUint32 0;
|
||||
};
|
||||
|
||||
"org/gnome/desktop/screensaver" = {
|
||||
lock-enabled = false;
|
||||
idle-activation-enabled = false;
|
||||
};
|
||||
|
||||
"org/gnome/evolution-data-server" = {
|
||||
migrated = true;
|
||||
};
|
||||
@@ -82,13 +272,12 @@ in
|
||||
"brave-browser.desktop"
|
||||
"org.gnome.Settings.desktop"
|
||||
"org.gnome.Nautilus.desktop"
|
||||
"Sovran_SystemsOS_Updater.desktop"
|
||||
"sovran-hub.desktop"
|
||||
"org.gnome.Software.desktop"
|
||||
"org.gnome.Geary.desktop"
|
||||
"org.gnome.Contacts.desktop"
|
||||
"org.gnome.Calendar.desktop"
|
||||
"sparrow-desktop.desktop"
|
||||
"sparrow.desktop"
|
||||
"Bisq.desktop"
|
||||
"bisq2.desktop"
|
||||
];
|
||||
@@ -96,6 +285,103 @@ in
|
||||
welcome-dialog-last-shown-version = "48.4";
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders" = {
|
||||
folder-children = [ "Browsers" "Office" "Terminal" "Chat" "Bitcoin" "Media" "System" ];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
||||
name = "Browsers";
|
||||
apps = [
|
||||
"brave-browser.desktop"
|
||||
"firefox.desktop"
|
||||
"org.gnome.Epiphany.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/Office" = {
|
||||
name = "Office";
|
||||
apps = [
|
||||
"libreoffice-writer.desktop"
|
||||
"libreoffice-calc.desktop"
|
||||
"libreoffice-impress.desktop"
|
||||
"libreoffice-draw.desktop"
|
||||
"libreoffice-base.desktop"
|
||||
"libreoffice-math.desktop"
|
||||
"libreoffice-startcenter.desktop"
|
||||
"org.gnome.TextEditor.desktop"
|
||||
"org.gnome.gedit.desktop"
|
||||
"org.gnome.Calculator.desktop"
|
||||
"org.gnome.Calendar.desktop"
|
||||
"org.gnome.Contacts.desktop"
|
||||
"org.gnome.Geary.desktop"
|
||||
"org.gnome.Evince.desktop"
|
||||
"onlyoffice-desktopeditors.desktop"
|
||||
"simple-scan.desktop"
|
||||
"system-config-printer.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/Terminal" = {
|
||||
name = "Terminal";
|
||||
apps = [
|
||||
"org.gnome.Terminal.desktop"
|
||||
"org.gnome.tweaks.desktop"
|
||||
"gparted.desktop"
|
||||
"htop.desktop"
|
||||
"btop.desktop"
|
||||
"ranger.desktop"
|
||||
"org.gnome.Console.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/Chat" = {
|
||||
name = "Chat";
|
||||
apps = [
|
||||
"element-desktop.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/Bitcoin" = {
|
||||
name = "Bitcoin";
|
||||
apps = [
|
||||
"sparrow.desktop"
|
||||
"Bisq.desktop"
|
||||
"bisq2.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/Media" = {
|
||||
name = "Media";
|
||||
apps = [
|
||||
"org.gnome.Loupe.desktop"
|
||||
"org.gnome.Totem.desktop"
|
||||
"org.gnome.Snapshot.desktop"
|
||||
"org.gnome.Weather.desktop"
|
||||
"org.gnome.Maps.desktop"
|
||||
"org.gnome.Clocks.desktop"
|
||||
"org.gnome.Music.desktop"
|
||||
"org.gnome.Characters.desktop"
|
||||
"org.gnome.font-viewer.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/desktop/app-folders/folders/System" = {
|
||||
name = "System";
|
||||
apps = [
|
||||
"org.gnome.Settings.desktop"
|
||||
"org.gnome.Nautilus.desktop"
|
||||
"org.gnome.Software.desktop"
|
||||
"sovran-hub.desktop"
|
||||
"bitwarden.desktop"
|
||||
"org.gnome.DiskUtility.desktop"
|
||||
"org.gnome.SystemMonitor.desktop"
|
||||
"org.gnome.Logs.desktop"
|
||||
"org.gnome.Connections.desktop"
|
||||
"org.gnome.baobab.desktop"
|
||||
"zenity.desktop"
|
||||
];
|
||||
};
|
||||
|
||||
"org/gnome/shell/extensions/dash-to-dock" = {
|
||||
background-color = "rgb(0,0,0)";
|
||||
background-opacity = 0.50000000000000001;
|
||||
@@ -146,4 +432,14 @@ in
|
||||
}
|
||||
];
|
||||
|
||||
}
|
||||
xdg.mime.defaultApplications = {
|
||||
"text/html" = "brave-browser.desktop";
|
||||
"x-scheme-handler/http" = "brave-browser.desktop";
|
||||
"x-scheme-handler/https" = "brave-browser.desktop";
|
||||
"x-scheme-handler/about" = "brave-browser.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-browser.desktop";
|
||||
};
|
||||
|
||||
environment.sessionVariables.BROWSER = "brave-browser";
|
||||
|
||||
}
|
||||
|
||||
@@ -12,20 +12,74 @@ lib.mkIf userExists {
|
||||
"d /home/${userName}/.ssh 0700 ${userName} users -"
|
||||
];
|
||||
|
||||
systemd.services.factory-ssh-keygen = {
|
||||
description = "Generate factory SSH key for ${userName} if missing";
|
||||
systemd.services.ssh-passphrase-setup = {
|
||||
description = "Generate per-device SSH key passphrase";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "factory-ssh-keygen.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.openssh pkgs.coreutils ];
|
||||
path = [ pkgs.pwgen pkgs.coreutils ];
|
||||
script = ''
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
ssh-keygen -q -N "gosovransystems" -t ed25519 -f "${keyPath}"
|
||||
if [ ! -f "/var/lib/secrets/ssh-passphrase" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
pwgen -s 20 1 > /var/lib/secrets/ssh-passphrase
|
||||
chmod 600 /var/lib/secrets/ssh-passphrase
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.factory-ssh-keygen = {
|
||||
description = "Generate or repair factory SSH key for ${userName}";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "ssh-passphrase-setup.service" ];
|
||||
requires = [ "ssh-passphrase-setup.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
|
||||
script = ''
|
||||
set -eu
|
||||
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
lock_file="${keyPath}.lock"
|
||||
|
||||
exec 9>"$lock_file"
|
||||
|
||||
if ! flock -n 9; then
|
||||
echo "Factory SSH key setup is already running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_factory_key() {
|
||||
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
|
||||
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
|
||||
chmod 600 "${keyPath}"
|
||||
chmod 644 "${keyPath}.pub"
|
||||
}
|
||||
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
generate_factory_key
|
||||
elif ! ssh-keygen -y -P "$PASSPHRASE" -f "${keyPath}" >/dev/null 2>&1; then
|
||||
backup_suffix="$(date -u +%Y%m%d_%H%M%S)-$$"
|
||||
backup_path="${keyPath}.bak-$backup_suffix"
|
||||
backup_index=0
|
||||
|
||||
while [ -e "$backup_path" ] || [ -e "$backup_path.pub" ]; do
|
||||
backup_index=$((backup_index + 1))
|
||||
backup_path="${keyPath}.bak-$backup_suffix-$backup_index"
|
||||
done
|
||||
|
||||
echo "Existing factory SSH key does not match current passphrase; backing it up to $backup_path and generating a replacement."
|
||||
mv "${keyPath}" "$backup_path"
|
||||
|
||||
if [ -f "${keyPath}.pub" ]; then
|
||||
mv "${keyPath}.pub" "$backup_path.pub"
|
||||
fi
|
||||
|
||||
generate_factory_key
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||
# and view a full audit log of every session event.
|
||||
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
|
||||
# restart services, and read logs — without full root or wallet access.
|
||||
#
|
||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
||||
@@ -39,4 +41,20 @@
|
||||
"d /var/lib/sovran-support 0700 sovran-support sovran-support -"
|
||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||
];
|
||||
|
||||
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
||||
# Grants only the minimum privileges needed for a support session.
|
||||
# Support staff cannot stop/disable/mask services or access wallet files.
|
||||
security.sudo.extraRules = [
|
||||
{
|
||||
users = [ "sovran-support" ];
|
||||
commands = [
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
|
||||
];
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
fonts = pkgs.liberation_ttf;
|
||||
|
||||
# ── Helper: change 'free' password and save it ─────────────
|
||||
change-free-password = pkgs.writeShellScriptBin "change-free-password" ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: must be run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "New password for free: "
|
||||
read -rs NEW_PASS
|
||||
echo
|
||||
echo -n "Confirm password: "
|
||||
read -rs CONFIRM
|
||||
echo
|
||||
|
||||
if [ "$NEW_PASS" != "$CONFIRM" ]; then
|
||||
echo "Passwords do not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$NEW_PASS" ]; then
|
||||
echo "Password cannot be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "free:$NEW_PASS" | ${pkgs.shadow}/bin/chpasswd
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "$NEW_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Password for 'free' updated and saved."
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── Make helper available system-wide ───────────────────────
|
||||
environment.systemPackages = [ change-free-password ];
|
||||
|
||||
# ── Shell aliases: intercept 'passwd free' ─────────────────
|
||||
programs.bash.interactiveShellInit = ''
|
||||
passwd() {
|
||||
if [ "$1" = "free" ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub and Magic Keys PDF will NOT be updated. ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
command passwd "$@"
|
||||
}
|
||||
'';
|
||||
|
||||
programs.fish.interactiveShellInit = ''
|
||||
function passwd --wraps passwd
|
||||
if test "$argv[1]" = "free"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub and Magic Keys PDF will NOT be updated. ║"
|
||||
echo "╚════════════════════════════��═════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
end
|
||||
command passwd $argv
|
||||
end
|
||||
'';
|
||||
|
||||
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Generate and set a random root password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
ROOT_PASS=$(pwgen -s 20 1)
|
||||
echo "root:$ROOT_PASS" | chpasswd
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1b. Save 'free' password on first boot ─────────────────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Save the initial 'free' user password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "free" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1c. Save Zeus/lndconnect URL for hub credentials ────────
|
||||
systemd.services.zeus-connect-setup = {
|
||||
description = "Save Zeus lndconnect URL";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
URL=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Zeus connect URL saved."
|
||||
else
|
||||
echo "No lndconnect URL available yet."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Refresh Zeus URL periodically (certs/macaroons may rotate)
|
||||
systemd.timers.zeus-connect-setup = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "30min";
|
||||
Unit = "zeus-connect-setup.service";
|
||||
};
|
||||
};
|
||||
|
||||
# ── 2. Timer: Check every 5 minutes ────────────────────────
|
||||
systemd.timers.generate-credentials-pdf = {
|
||||
description = "Periodically check if Magic Keys PDF needs regenerating";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "30s";
|
||||
OnUnitActiveSec = "5min";
|
||||
Unit = "generate-credentials-pdf.service";
|
||||
};
|
||||
};
|
||||
|
||||
# ── 3. Generate the Magic Keys PDF ─────────────────────────
|
||||
systemd.services.generate-credentials-pdf = {
|
||||
description = "Generate Magic Keys PDF for Sovran_SystemsOS";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
};
|
||||
|
||||
path = [
|
||||
pkgs.pandoc
|
||||
pkgs.typst
|
||||
pkgs.coreutils
|
||||
pkgs.qrencode
|
||||
pkgs.gnugrep
|
||||
fonts
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
|
||||
environment = {
|
||||
TYPST_FONT_PATHS = "${fonts}/share/fonts";
|
||||
};
|
||||
|
||||
script = ''
|
||||
DOC_DIR="/home/free/Documents"
|
||||
OUTPUT="$DOC_DIR/Sovran_SystemsOS_Magic_Keys.pdf"
|
||||
WORK_DIR="/tmp/magic_keys_build"
|
||||
FILE="$WORK_DIR/magic_keys.md"
|
||||
HASH_FILE="/var/lib/secrets/.magic-keys-hash"
|
||||
|
||||
FENCE='```'
|
||||
|
||||
# ── Collect all secret sources into a single hash ──
|
||||
SECRET_SOURCES=""
|
||||
for f in \
|
||||
/var/lib/secrets/root-password \
|
||||
/var/lib/secrets/free-password \
|
||||
/etc/nix-bitcoin-secrets/rtl-password \
|
||||
/var/lib/tor/onion/rtl/hostname \
|
||||
/var/lib/tor/onion/electrs/hostname \
|
||||
/var/lib/tor/onion/bitcoind/hostname \
|
||||
/var/lib/secrets/matrix-users \
|
||||
/var/lib/gnome-remote-desktop/rdp-credentials \
|
||||
/var/lib/secrets/nextcloud-admin \
|
||||
/var/lib/secrets/wordpress-admin \
|
||||
/var/lib/secrets/vaultwarden/vaultwarden.env \
|
||||
/var/lib/domains/vaultwarden \
|
||||
/var/lib/domains/btcpayserver \
|
||||
/var/lib/secrets/zeus-connect-url; do
|
||||
if [ -f "$f" ]; then
|
||||
SECRET_SOURCES="$SECRET_SOURCES$(cat "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
# Add lndconnect URL to hash sources (changes if certs/macaroons rotate)
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
SECRET_SOURCES="$SECRET_SOURCES$(lndconnect --url 2>/dev/null || true)"
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
SECRET_SOURCES="$SECRET_SOURCES$(lnconnect-clnrest --url 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
CURRENT_HASH=$(echo -n "$SECRET_SOURCES" | sha256sum | cut -d' ' -f1)
|
||||
OLD_HASH=""
|
||||
if [ -f "$HASH_FILE" ]; then
|
||||
OLD_HASH=$(cat "$HASH_FILE")
|
||||
fi
|
||||
|
||||
# ── Skip if PDF exists and nothing changed ──
|
||||
if [ -f "$OUTPUT" ] && [ "$CURRENT_HASH" = "$OLD_HASH" ]; then
|
||||
echo "No changes detected, skipping PDF regeneration."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changes detected (or PDF missing), regenerating..."
|
||||
mkdir -p "$DOC_DIR" "$WORK_DIR"
|
||||
|
||||
# ── Read secrets (default to placeholder if missing) ──
|
||||
read_secret() { if [ -f "$1" ]; then cat "$1"; else echo "$2"; fi; }
|
||||
|
||||
ROOT_PASS=$(read_secret /var/lib/secrets/root-password "Generating...")
|
||||
FREE_PASS=$(read_secret /var/lib/secrets/free-password "free")
|
||||
RTL_PASS=$(read_secret /etc/nix-bitcoin-secrets/rtl-password "Not found")
|
||||
RTL_ONION=$(read_secret /var/lib/tor/onion/rtl/hostname "Not generated yet")
|
||||
ELECTRS_ONION=$(read_secret /var/lib/tor/onion/electrs/hostname "Not generated yet")
|
||||
BITCOIN_ONION=$(read_secret /var/lib/tor/onion/bitcoind/hostname "Not generated yet")
|
||||
|
||||
# ── Generate Zeus QR code PNG if lndconnect URL is available ──
|
||||
ZEUS_URL=""
|
||||
HAS_ZEUS_QR=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
ZEUS_URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
ZEUS_URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$ZEUS_URL" ]; then
|
||||
qrencode -o "$WORK_DIR/zeus-qr.png" -s 4 -m 1 -l H "$ZEUS_URL" 2>/dev/null && HAS_ZEUS_QR="1"
|
||||
fi
|
||||
|
||||
# ── Build the Markdown document ──
|
||||
cat > "$FILE" << ENDOFFILE
|
||||
---
|
||||
title: "Sovran SystemsOS Magic Keys"
|
||||
---
|
||||
|
||||
# Your Sovran SystemsOS Magic Keys! 🗝️
|
||||
|
||||
Welcome to your new computer! We have built a lot of cool secret forts (services) for you. To get into your forts, you need your magic keys (passwords).
|
||||
|
||||
Here are all of your keys in one place. **Keep this document safe and do not share it with strangers!**
|
||||
|
||||
> **How this document works:** This PDF is automatically generated by your computer. If any of your passwords, services, or connection details change, this document will automatically update itself within a few minutes. You can always find the latest version right here in your Documents folder. If you accidentally delete it, don't worry — your computer will recreate it for you!
|
||||
|
||||
## 🖥️ Your Computer
|
||||
These are the master keys to the actual machine.
|
||||
|
||||
### 1. Main Screen Unlock (The 'free' account)
|
||||
When you turn the computer on, it usually logs you in automatically. However, if the screen goes to sleep, or **if you enable Remote Desktop (RDP)**, you will need this to log in:
|
||||
- **Username:** \`free\`
|
||||
- **Password:** \`$FREE_PASS\`
|
||||
|
||||
🚨 **VERY IMPORTANT:** You MUST write this password down and keep it safe! If you lose it, you will be locked out of your computer!
|
||||
|
||||
### 2. The Big Boss (Root)
|
||||
Sometimes a pop-up box might ask for an Administrator (Root) password to change a setting. We created a super-secret password just for this!
|
||||
- **Root Password:** \`$ROOT_PASS\`
|
||||
|
||||
### 3. The Hacker Terminal (\`ssh root@localhost\`)
|
||||
Because your main account is so safe, you cannot just type normal commands to become the boss. If you open a black terminal box and want to make big changes, you must use your special factory key!
|
||||
|
||||
Type this exact command into the terminal:
|
||||
\`ssh root@localhost\`
|
||||
|
||||
When it asks for a passphrase, type:
|
||||
- **Terminal Password:** \`gosovransystems\`
|
||||
ENDOFFILE
|
||||
|
||||
# --- BITCOIN ECOSYSTEM ---
|
||||
if [ -f "/etc/nix-bitcoin-secrets/rtl-password" ] || [ -f "/var/lib/tor/onion/rtl/hostname" ]; then
|
||||
cat >> "$FILE" << BITCOIN
|
||||
|
||||
## ⚡ Your Bitcoin & Lightning Node
|
||||
Your computer is a real Bitcoin node! It talks to the network secretly using Tor. Here is how to connect your wallet apps to it:
|
||||
|
||||
### 1. Ride The Lightning (RTL)
|
||||
*This is the control panel for your Lightning Node.*
|
||||
Open the **Tor Browser** and go to this website. Use this password to log in:
|
||||
- **Website:** \`http://$RTL_ONION\`
|
||||
- **Password:** \`$RTL_PASS\`
|
||||
|
||||
### 2. Electrs (Your Private Bank Teller)
|
||||
*If you use a wallet app on your phone or computer (like Sparrow or BlueWallet), tell it to connect here so nobody can spy on your money!*
|
||||
- **Tor Address:** \`$ELECTRS_ONION\`
|
||||
- **Port:** \`50001\`
|
||||
|
||||
### 3. Bitcoin Core
|
||||
*This is the heartbeat of your node. It uses this address to talk to other Bitcoiners securely.*
|
||||
- **Tor Address:** \`$BITCOIN_ONION\`
|
||||
BITCOIN
|
||||
fi
|
||||
|
||||
# --- ZEUS MOBILE WALLET QR CODE ---
|
||||
if [ "$HAS_ZEUS_QR" = "1" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 📱 Connect Zeus Mobile Wallet" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "Take your Bitcoin Lightning node anywhere in the world! Scan this QR code with the **Zeus** app on your phone to instantly connect your mobile wallet to your Lightning node." >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "1. Download **Zeus** from the App Store or Google Play" >> "$FILE"
|
||||
echo "2. Open Zeus and tap **\"Scan Node Config\"**" >> "$FILE"
|
||||
echo "3. Point your phone's camera at this QR code:" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "{ width=200px }" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "That's it! You're now mobile. Send and receive Bitcoin anywhere in the world, powered by your very own node! ⚡" >> "$FILE"
|
||||
elif [ -n "$ZEUS_URL" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 📱 Connect Zeus Mobile Wallet" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "Take your Bitcoin Lightning node anywhere in the world! Paste this connection URL into the **Zeus** app on your phone:" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "1. Download **Zeus** from the App Store or Google Play" >> "$FILE"
|
||||
echo "2. Open Zeus and tap **\"Scan Node Config\"** then **\"Paste Node Config\"**" >> "$FILE"
|
||||
echo "3. Paste this URL:" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
echo "$ZEUS_URL" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "That's it! You're now mobile. Send and receive Bitcoin anywhere in the world, powered by your very own node! ⚡" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- MATRIX / ELEMENT ---
|
||||
if [ -f "/var/lib/secrets/matrix-users" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 💬 Your Private Chat (Matrix / Element)" >> "$FILE"
|
||||
echo "This is your very own private messaging app! Log in using an app like Element with these details:" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/secrets/matrix-users >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- GNOME RDP ---
|
||||
if [ -f "/var/lib/gnome-remote-desktop/rdp-credentials" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 🌎 Connect from Far Away (Remote Desktop)" >> "$FILE"
|
||||
echo "This lets you control your computer screen from another device!" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/gnome-remote-desktop/rdp-credentials >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- NEXTCLOUD ---
|
||||
if [ -f "/var/lib/secrets/nextcloud-admin" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## ☁️ Your Personal Cloud (Nextcloud)" >> "$FILE"
|
||||
echo "This is like your own private Google Drive!" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/secrets/nextcloud-admin >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- WORDPRESS ---
|
||||
if [ -f "/var/lib/secrets/wordpress-admin" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 📝 Your Website (WordPress)" >> "$FILE"
|
||||
echo "This is your very own website where you can write blogs or make pages." >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/secrets/wordpress-admin >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- VAULTWARDEN ---
|
||||
if [ -f "/var/lib/domains/vaultwarden" ]; then
|
||||
DOMAIN=$(cat /var/lib/domains/vaultwarden)
|
||||
VW_ADMIN_TOKEN="Not found"
|
||||
if [ -f "/var/lib/secrets/vaultwarden/vaultwarden.env" ]; then
|
||||
VW_ADMIN_TOKEN=$(grep -oP 'ADMIN_TOKEN=\K.*' /var/lib/secrets/vaultwarden/vaultwarden.env || echo "Not found")
|
||||
fi
|
||||
echo "" >> "$FILE"
|
||||
echo "## 🔐 Your Password Manager (Vaultwarden)" >> "$FILE"
|
||||
echo "This keeps all your other passwords safe! Go to this website to use it:" >> "$FILE"
|
||||
echo "- **Website:** https://$DOMAIN" >> "$FILE"
|
||||
echo "- **Admin Panel:** https://$DOMAIN/admin" >> "$FILE"
|
||||
echo "- **Admin Token:** \`$VW_ADMIN_TOKEN\`" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "*(Create your own account on the main page. Use the Admin Token to access the admin panel and manage your server.)*" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- BTCPAY SERVER ---
|
||||
if [ -f "/var/lib/domains/btcpayserver" ]; then
|
||||
DOMAIN=$(cat /var/lib/domains/btcpayserver)
|
||||
echo "" >> "$FILE"
|
||||
echo "## ₿ Your Bitcoin Store (BTCPay Server)" >> "$FILE"
|
||||
echo "This lets you accept Bitcoin like a real shop!" >> "$FILE"
|
||||
echo "- **Website:** https://$DOMAIN" >> "$FILE"
|
||||
echo "*(You make up your own Admin Password the first time you visit!)*" >> "$FILE"
|
||||
fi
|
||||
|
||||
# ── Generate PDF (cd into work dir so Typst finds images) ──
|
||||
cd "$WORK_DIR"
|
||||
pandoc magic_keys.md -o "$OUTPUT" --pdf-engine=typst \
|
||||
-V mainfont="Liberation Sans" \
|
||||
-V monofont="Liberation Mono"
|
||||
|
||||
chown free:users "$OUTPUT"
|
||||
|
||||
# ── Save hash so we skip next time if nothing changed ──
|
||||
mkdir -p "$(dirname "$HASH_FILE")"
|
||||
echo "$CURRENT_HASH" > "$HASH_FILE"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
echo "PDF generated successfully."
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
# ── Helper: change 'free' password and save it ─────────────
|
||||
change-free-password = pkgs.writeShellScriptBin "change-free-password" ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: must be run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "New password for free: "
|
||||
read -rs NEW_PASS
|
||||
echo
|
||||
echo -n "Confirm password: "
|
||||
read -rs CONFIRM
|
||||
echo
|
||||
|
||||
if [ "$NEW_PASS" != "$CONFIRM" ]; then
|
||||
echo "Passwords do not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$NEW_PASS" ]; then
|
||||
echo "Password cannot be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "free:$NEW_PASS" | ${pkgs.shadow}/bin/chpasswd
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "$NEW_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Password for 'free' updated and saved."
|
||||
# Delete the old GNOME Keyring databases so a fresh one is created on next GDM login.
|
||||
rm -f /home/free/.local/share/keyrings/*.keyring
|
||||
echo "GNOME Keyring files cleared — a fresh keyring will be created on next login."
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── Make helper available system-wide ───────────────────────
|
||||
environment.systemPackages = [ change-free-password ];
|
||||
|
||||
# ── Shell aliases: intercept 'passwd free' ─────────────────
|
||||
programs.bash.interactiveShellInit = ''
|
||||
passwd() {
|
||||
if [ "$1" = "free" ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub credentials view will NOT be updated. ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
command passwd "$@"
|
||||
}
|
||||
'';
|
||||
|
||||
programs.fish.interactiveShellInit = ''
|
||||
function passwd --wraps passwd
|
||||
if test "$argv[1]" = "free"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub credentials view will NOT be updated. ║"
|
||||
echo "╚════════════════════════════��═════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
end
|
||||
command passwd $argv
|
||||
end
|
||||
'';
|
||||
|
||||
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Generate and set a random root password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
echo "root:$(cat "$SECRET_FILE")" | chpasswd
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1b. Generate random 'free' password on first boot ──────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Generate and set a random 'free' user password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
after = [ "systemd-user-sessions.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
|
||||
|
||||
if [ -f "$SECRET_FILE" ]; then
|
||||
echo "free:$(cat "$SECRET_FILE")" | chpasswd
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHADOW_HASH=""
|
||||
while IFS=: read -r user hash _; do
|
||||
if [ "$user" = "free" ]; then
|
||||
SHADOW_HASH="$hash"
|
||||
break
|
||||
fi
|
||||
done < /etc/shadow
|
||||
|
||||
HAS_REAL_HASH=0
|
||||
case "$SHADOW_HASH" in
|
||||
""|"!"|"*"|"!!"|"!"*|"*"*)
|
||||
HAS_REAL_HASH=0
|
||||
;;
|
||||
*)
|
||||
HAS_REAL_HASH=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$HAS_REAL_HASH" -eq 1 ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
touch "$PENDING_FILE"
|
||||
chmod 600 "$PENDING_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "free:$FREE_PASS" | chpasswd
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.free-password-migration = {
|
||||
description = "Generate and set 'free' password for migrated machines";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
after = [ "systemd-user-sessions.service" "free-password-setup.service" ];
|
||||
requires = [ "free-password-setup.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass"
|
||||
|
||||
[ -f "$PENDING_FILE" ] || exit 0
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE"
|
||||
chmod 600 "$NEWPASS_FILE"
|
||||
rm -f "$PENDING_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
||||
};
|
||||
|
||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||
|
||||
@@ -68,35 +68,54 @@ $MATRIX {
|
||||
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
|
||||
respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\`
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
|
||||
}
|
||||
|
||||
$ELEMENT_CALLING {
|
||||
handle /livekit/jwt/sfu/get {
|
||||
# Route all current lk-jwt-service authorization endpoints to port 8073,
|
||||
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
|
||||
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
|
||||
handle @lk_jwt {
|
||||
uri strip_prefix /livekit/jwt
|
||||
reverse_proxy [::1]:8073 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Server {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy localhost:7880
|
||||
reverse_proxy localhost:7880 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
|
||||
####### LIVEKIT RUNTIME CONFIG #######
|
||||
systemd.services.livekit-runtime-config = {
|
||||
description = "Generate LiveKit runtime config from domain files";
|
||||
####### LIVEKIT TURN SETUP (runtime cert + config) #######
|
||||
# Replaces the old dead livekit-runtime-config.service. At runtime this:
|
||||
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
|
||||
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
|
||||
# so LoadCredential can stage them for the (DynamicUser) livekit unit
|
||||
# * detects the primary network interface from the IPv4 default route so
|
||||
# LiveKit only advertises real ICE candidates — not VPN/container/private
|
||||
# addresses from interfaces like Tailscale or Docker bridges
|
||||
# * writes a complete LiveKit config (with turn.domain and interface
|
||||
# substituted) that the overridden ExecStart loads.
|
||||
systemd.services.livekit-turn-setup = {
|
||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
||||
before = [ "livekit.service" ];
|
||||
after = [ "livekit-key-setup.service" ];
|
||||
requiredBy = [ "livekit.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
@@ -106,20 +125,63 @@ EOF
|
||||
unitConfig = {
|
||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
||||
script = ''
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
|
||||
mkdir -p /run/livekit
|
||||
|
||||
cat > /run/livekit/runtime-config.yaml <<EOF
|
||||
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
||||
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
||||
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
||||
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
||||
cp "$CRT" /var/lib/livekit/turn.crt
|
||||
cp "$KEY" /var/lib/livekit/turn.key
|
||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||
|
||||
# Detect the primary network interface from the IPv4 default route.
|
||||
# Restricting LiveKit to this single interface prevents it from
|
||||
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
|
||||
# Docker bridges) that remote peers cannot reach, which causes all
|
||||
# ICE negotiation attempts to fail with responsesReceived: 0.
|
||||
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
|
||||
if [ -z "$IFACE" ]; then
|
||||
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
|
||||
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
|
||||
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
|
||||
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Detected primary network interface: $IFACE"
|
||||
|
||||
# Generate the full LiveKit config the daemon will load. turn.domain and
|
||||
# rtc.interfaces.includes are only known at runtime, so they are
|
||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||
# copies under /run/credentials.
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
port: 7880
|
||||
rtc:
|
||||
use_external_ip: true
|
||||
skip_external_ip_validation: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
room:
|
||||
auto_create: false
|
||||
turn:
|
||||
enabled: true
|
||||
domain: $MATRIX
|
||||
cert_file: /var/lib/livekit/$MATRIX.crt
|
||||
key_file: /var/lib/livekit/$MATRIX.key
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
cert_file: /run/credentials/livekit.service/turn-cert
|
||||
key_file: /run/credentials/livekit.service/turn-key
|
||||
EOF
|
||||
|
||||
chmod 640 /run/livekit/runtime-config.yaml
|
||||
chmod 644 /run/livekit/livekit.yaml
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -130,7 +192,11 @@ EOF
|
||||
keyFile = livekitKeyFile;
|
||||
settings = {
|
||||
rtc.use_external_ip = true;
|
||||
rtc.udp_port = "7882-7894";
|
||||
rtc.skip_external_ip_validation = true;
|
||||
rtc.tcp_port = 7881;
|
||||
rtc.udp_port = 7882;
|
||||
rtc.port_range_start = 30000;
|
||||
rtc.port_range_end = 40000;
|
||||
room.auto_create = false;
|
||||
turn = {
|
||||
enabled = true;
|
||||
@@ -140,9 +206,27 @@ EOF
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 7881 ];
|
||||
# Override ExecStart to load the runtime-generated config (which carries the
|
||||
# runtime-only turn.domain), mirroring the Caddy ExecStart override pattern in
|
||||
# modules/core/caddy.nix. Deliver the TURN cert/key via LoadCredential so they
|
||||
# are readable under the upstream unit's DynamicUser=true sandbox without
|
||||
# weakening it. Everything else about the standard unit is left intact.
|
||||
systemd.services.livekit.serviceConfig.ExecStart = lib.mkForce [
|
||||
""
|
||||
"${pkgs.livekit}/bin/livekit-server --config /run/credentials/livekit.service/livekit-config --key-file /run/credentials/livekit.service/livekit-secrets"
|
||||
];
|
||||
|
||||
systemd.services.livekit.serviceConfig.LoadCredential = [
|
||||
"livekit-config:/run/livekit/livekit.yaml"
|
||||
"livekit-secrets:${livekitKeyFile}"
|
||||
"turn-cert:/var/lib/livekit/turn.crt"
|
||||
"turn-key:/var/lib/livekit/turn.key"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 7882; to = 7894; }
|
||||
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||
];
|
||||
|
||||
####### JWT SERVICE RUNTIME CONFIG #######
|
||||
@@ -162,11 +246,13 @@ EOF
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
|
||||
mkdir -p /run/lk-jwt-service
|
||||
|
||||
cat > /run/lk-jwt-service/env <<EOF
|
||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
||||
EOF
|
||||
|
||||
chmod 640 /run/lk-jwt-service/env
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
./core/sovran_systemsos-desktop.nix
|
||||
./core/sshd-localhost.nix
|
||||
./core/sovran-hub.nix
|
||||
./core/legacy-cleanup.nix
|
||||
./core/remote-deploy.nix
|
||||
./core/no-sleep.nix
|
||||
./core/cpu-performance.nix
|
||||
./core/local-domain-loopback.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
./Sovran_SystemsOS_File_Fixes_And_New_Services.nix
|
||||
./credentials-pdf.nix
|
||||
./credentials.nix
|
||||
|
||||
# ── Services (default ON — disable in custom.nix) ─────────
|
||||
./synapse.nix
|
||||
@@ -28,7 +32,6 @@
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./bip110.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
|
||||
@@ -53,7 +53,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
# ── Fully automated Nextcloud setup ───────────────────────
|
||||
systemd.services.nextcloud-init = {
|
||||
description = "Download, extract, and fully configure Nextcloud";
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-mypool.service" "nextcloud-db-init.service" ];
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -67,13 +67,13 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
path = with pkgs; [ curl unzip php pwgen coreutils ];
|
||||
path = with pkgs; [ curl unzip php pwgen coreutils shadow util-linux ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||
DATA_DIR="/var/lib/www/nextcloud-data"
|
||||
DATA_DIR="/var/lib/nextcloud"
|
||||
DOMAIN=$(cat /var/lib/domains/nextcloud)
|
||||
DB_NAME="nextclouddb"
|
||||
DB_USER="ncusr"
|
||||
@@ -81,6 +81,11 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
DB_HOST="localhost"
|
||||
ADMIN_USER=$(pwgen -s 16 1)
|
||||
ADMIN_PASS=$(pwgen -s 24 1)
|
||||
SERVER_ID=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
if [ -z "$SERVER_ID" ]; then
|
||||
echo "Failed to generate Nextcloud server_id"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo " Nextcloud Automated Installation"
|
||||
@@ -92,24 +97,26 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
curl -L -o "$TEMP_DIR/nextcloud.zip" "https://download.nextcloud.com/server/releases/latest.zip"
|
||||
unzip -q "$TEMP_DIR/nextcloud.zip" -d "$TEMP_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp -a "$TEMP_DIR/nextcloud/"* "$INSTALL_DIR/"
|
||||
cp -a "$TEMP_DIR/nextcloud/." "$INSTALL_DIR/"
|
||||
rm -rf "$TEMP_DIR"
|
||||
echo "Download complete."
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
chown -R caddy:root "$INSTALL_DIR"
|
||||
chown -R caddy:root "$DATA_DIR"
|
||||
chown -R caddy:php "$INSTALL_DIR"
|
||||
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
|
||||
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
|
||||
chmod -R 770 "$INSTALL_DIR/apps"
|
||||
chmod -R 770 "$INSTALL_DIR/config"
|
||||
chmod -R 770 "$DATA_DIR"
|
||||
|
||||
if [ ! -d "$DATA_DIR" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R caddy:php "$DATA_DIR"
|
||||
chmod -R 770 "$DATA_DIR"
|
||||
fi
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
for i in $(seq 1 30); do
|
||||
if su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then
|
||||
if /run/wrappers/bin/su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then
|
||||
echo "Database ready."
|
||||
break
|
||||
fi
|
||||
@@ -117,7 +124,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
done
|
||||
|
||||
echo "Running Nextcloud installation..."
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ maintenance:install \
|
||||
--database 'pgsql' \
|
||||
--database-name '$DB_NAME' \
|
||||
@@ -129,19 +136,39 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
--data-dir '$DATA_DIR'
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN'
|
||||
php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN'
|
||||
php $INSTALL_DIR/occ config:system:set overwritehost --value='$DOMAIN'
|
||||
php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https'
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ config:system:set trusted_proxies 0 --value='127.0.0.1'
|
||||
php $INSTALL_DIR/occ config:system:set trusted_proxies 1 --value='::1'
|
||||
php $INSTALL_DIR/occ config:system:set forwarded_for_headers 0 --value='HTTP_X_FORWARDED_FOR'
|
||||
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
||||
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
|
||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
|
||||
php $INSTALL_DIR/occ background:cron
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ integrity:check-core
|
||||
php $INSTALL_DIR/occ maintenance:repair
|
||||
php $INSTALL_DIR/occ db:add-missing-indices
|
||||
php $INSTALL_DIR/occ db:add-missing-columns
|
||||
php $INSTALL_DIR/occ db:add-missing-primary-keys
|
||||
php $INSTALL_DIR/occ maintenance:repair --include-expensive
|
||||
# AppAPI deploy daemon warnings are avoided by disabling app_api when present.
|
||||
if php $INSTALL_DIR/occ app:info app_api >/dev/null 2>&1; then
|
||||
php $INSTALL_DIR/occ app:disable app_api
|
||||
fi
|
||||
"
|
||||
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ app:install calendar || true
|
||||
php $INSTALL_DIR/occ app:install contacts || true
|
||||
php $INSTALL_DIR/occ app:install tasks || true
|
||||
@@ -172,16 +199,83 @@ CREDS
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.nextcloud-detect-existing = {
|
||||
description = "Detect pre-existing Nextcloud installation and populate hub credentials";
|
||||
after = [ "postgresql.service" ];
|
||||
wants = [ "postgresql.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
unitConfig = {
|
||||
ConditionPathExists = [
|
||||
"/var/lib/www/nextcloud/config/config.php"
|
||||
"!/var/lib/secrets/nextcloud-admin"
|
||||
];
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
path = with pkgs; [ coreutils gnused ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
CREDS_FILE="/var/lib/secrets/nextcloud-admin"
|
||||
DOMAIN_FILE="/var/lib/domains/nextcloud"
|
||||
DOMAIN="your-domain"
|
||||
|
||||
if [ -f "$DOMAIN_FILE" ]; then
|
||||
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
|
||||
if [ -n "$FILE_DOMAIN" ]; then
|
||||
DOMAIN="$FILE_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
cat > "$CREDS_FILE" << CREDS
|
||||
Nextcloud (Pre-existing Installation)
|
||||
═══════════════════════════════════════
|
||||
URL: https://$DOMAIN/
|
||||
Note: This Nextcloud was installed before Sovran_SystemsOS.
|
||||
Use your existing admin credentials to log in.
|
||||
Reset: sudo -u caddy php /var/lib/www/nextcloud/occ user:resetpassword <username>
|
||||
CREDS
|
||||
chmod 600 "$CREDS_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
services.cron.systemCronJobs = [
|
||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||
];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/www 0755 caddy root -"
|
||||
"d /var/lib/www/nextcloud 0750 caddy root -"
|
||||
"d /var/lib/www/nextcloud-data 0770 caddy root -"
|
||||
"d /var/lib/www 0755 caddy php -"
|
||||
"d /var/lib/www/nextcloud 0750 caddy php -"
|
||||
"d /var/lib/nextcloud 0770 caddy php -"
|
||||
];
|
||||
|
||||
services.phpfpm.pools.nextcloud = {
|
||||
user = "caddy";
|
||||
group = "php";
|
||||
phpPackage = config.sovran_systemsOS.phpPackage;
|
||||
phpOptions = lib.mkAfter ''
|
||||
output_buffering = 0
|
||||
'';
|
||||
settings = {
|
||||
"pm" = "dynamic";
|
||||
"pm.max_children" = 75;
|
||||
"pm.start_servers" = 10;
|
||||
"pm.min_spare_servers" = 5;
|
||||
"pm.max_spare_servers" = 20;
|
||||
"pm.max_requests" = 500;
|
||||
"clear_env" = "no";
|
||||
"listen" = "/run/phpfpm/nextcloud.sock";
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [ unzip ];
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
|
||||
@@ -28,39 +28,30 @@ let
|
||||
};
|
||||
in
|
||||
|
||||
{
|
||||
users.users = {
|
||||
|
||||
php = {
|
||||
isSystemUser = true;
|
||||
createHome = false;
|
||||
uid = 7777;
|
||||
};
|
||||
{
|
||||
options.sovran_systemsOS.phpPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = custom-php;
|
||||
description = "Shared PHP package with all extensions for Sovran_SystemsOS services";
|
||||
};
|
||||
|
||||
users.users.php.group = "php";
|
||||
|
||||
users.groups.php = {};
|
||||
config = {
|
||||
users.users = {
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
|
||||
custom-php
|
||||
];
|
||||
|
||||
services.phpfpm.pools = {
|
||||
mypool = {
|
||||
user = "caddy";
|
||||
group = "php";
|
||||
phpPackage = custom-php;
|
||||
settings = {
|
||||
"pm" = "dynamic";
|
||||
"pm.max_children" = 75;
|
||||
"pm.start_servers" = 10;
|
||||
"pm.min_spare_servers" = 5;
|
||||
"pm.max_spare_servers" = 20;
|
||||
"pm.max_requests" = 500;
|
||||
"clear_env" = "no";
|
||||
php = {
|
||||
isSystemUser = true;
|
||||
createHome = false;
|
||||
uid = 7777;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
users.users.php.group = "php";
|
||||
|
||||
users.groups.php = {};
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
|
||||
custom-php
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,63 +2,104 @@
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
|
||||
users.users.gnome-remote-desktop = {
|
||||
isSystemUser = true;
|
||||
group = "gnome-remote-desktop";
|
||||
home = "/var/lib/gnome-remote-desktop";
|
||||
createHome = true;
|
||||
};
|
||||
users.groups.gnome-remote-desktop = {};
|
||||
|
||||
# Enable the GNOME Remote Desktop service at the system level
|
||||
services.gnome.gnome-remote-desktop.enable = true;
|
||||
|
||||
# Open RDP port in the firewall
|
||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
||||
|
||||
# Ensure the service only starts after setup succeeds
|
||||
systemd.services.gnome-remote-desktop = {
|
||||
wantedBy = [ "graphical.target" ];
|
||||
after = [ "gnome-remote-desktop-setup.service" ];
|
||||
requires = [ "gnome-remote-desktop-setup.service" ];
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
];
|
||||
|
||||
systemd.services.gnome-remote-desktop-setup = {
|
||||
description = "Configure GNOME Remote Desktop RDP";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wantedBy = [ "graphical.target" ];
|
||||
before = [ "gnome-remote-desktop.service" ];
|
||||
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"dbus.service"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
wants = [
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
TimeoutStartSec = "2min";
|
||||
};
|
||||
path = [
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.polkit
|
||||
pkgs.openssl
|
||||
pkgs.hostname
|
||||
pkgs.coreutils
|
||||
pkgs.gawk
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.hostname
|
||||
pkgs.openssl
|
||||
pkgs.systemd
|
||||
];
|
||||
script = ''
|
||||
# Ensure directory structure exists
|
||||
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
|
||||
set -euo pipefail
|
||||
|
||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
||||
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
|
||||
# when the caller is root. NixOS exposes the required setuid wrapper at
|
||||
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
|
||||
# must not shadow it. Prepend the wrapper directory so every subsequent
|
||||
# grdctl --system resolves the correct binary.
|
||||
export PATH="/run/wrappers/bin:$PATH"
|
||||
|
||||
STATE_DIR="/var/lib/gnome-remote-desktop"
|
||||
TLS_DIR="$STATE_DIR/tls"
|
||||
USERNAME_FILE="$STATE_DIR/rdp-username"
|
||||
PASSWORD_FILE="$STATE_DIR/rdp-password"
|
||||
CRED_FILE="$STATE_DIR/rdp-credentials"
|
||||
DEFAULT_USERNAME="sovran"
|
||||
|
||||
grdctl_system() {
|
||||
local rc=0
|
||||
|
||||
if timeout --kill-after=5s 10s \
|
||||
grdctl --system "$@"; then
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
||||
echo "grdctl command timed out: $*" >&2
|
||||
fi
|
||||
echo "grdctl command failed (exit $rc): $*" >&2
|
||||
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
|
||||
chmod 700 \
|
||||
"$STATE_DIR" \
|
||||
"$STATE_DIR/.local" \
|
||||
"$STATE_DIR/.local/share" \
|
||||
"$STATE_DIR/.local/share/gnome-remote-desktop" \
|
||||
"$TLS_DIR"
|
||||
|
||||
# Regenerate TLS certificate if missing OR if ownership is wrong
|
||||
# (disable/re-enable cycle can break ownership or grdctl state)
|
||||
NEED_REGEN=0
|
||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||
NEED_REGEN=1
|
||||
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
|
||||
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
|
||||
NEED_REGEN=1
|
||||
fi
|
||||
|
||||
if [ "$NEED_REGEN" = "1" ]; then
|
||||
mkdir -p "$TLS_DIR"
|
||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-sha256 -nodes -days 3650 \
|
||||
@@ -68,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
echo "Generated new RDP TLS certificate"
|
||||
fi
|
||||
|
||||
# Always fix ownership and permissions (handles re-enable after disable)
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||
|
||||
# Configure TLS certificate
|
||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
if [ ! -f "$USERNAME_FILE" ]; then
|
||||
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
|
||||
if [ -z "$USERNAME" ]; then
|
||||
USERNAME="$DEFAULT_USERNAME"
|
||||
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
if [ "''${#USERNAME}" -gt 32 ]; then
|
||||
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$USERNAME" in
|
||||
[A-Za-z_][A-Za-z0-9_-]*)
|
||||
;;
|
||||
*)
|
||||
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
|
||||
chmod 600 "$USERNAME_FILE"
|
||||
|
||||
# Generate password on first boot only
|
||||
PASSWORD=""
|
||||
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
|
||||
PASSWORD=$(openssl rand -base64 16)
|
||||
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
|
||||
else
|
||||
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
openssl rand -base64 16 > "$PASSWORD_FILE"
|
||||
fi
|
||||
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
echo "RDP password file is empty: $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "''${#PASSWORD}" -lt 8 ]; then
|
||||
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
|
||||
chmod 600 "$PASSWORD_FILE"
|
||||
|
||||
LOCAL_IP="$(hostname -I | awk '{print $1}')"
|
||||
if [ -z "$LOCAL_IP" ]; then
|
||||
LOCAL_IP="127.0.0.1"
|
||||
fi
|
||||
|
||||
# Write username to a separate file for the hub
|
||||
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
|
||||
|
||||
# Get current IP address
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
# Always rewrite the credentials file with the current IP
|
||||
cat > "$CRED_FILE" <<EOF
|
||||
========================================
|
||||
GNOME Remote Desktop (RDP) Credentials
|
||||
========================================
|
||||
|
||||
Username: sovran
|
||||
Username: $USERNAME
|
||||
Password: $PASSWORD
|
||||
|
||||
Connect from any RDP client to:
|
||||
@@ -109,13 +170,24 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
========================================
|
||||
EOF
|
||||
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
|
||||
chmod 600 "$CRED_FILE"
|
||||
|
||||
# Enable RDP backend and set credentials
|
||||
grdctl --system rdp enable
|
||||
grdctl --system rdp set-credentials sovran "$PASSWORD"
|
||||
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
|
||||
# before any grdctl --system call. Absence means the system was booted
|
||||
# without security.wrappers or the wrapper directory is not mounted yet.
|
||||
if ! test -x /run/wrappers/bin/pkexec; then
|
||||
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
|
||||
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grdctl_system rdp enable
|
||||
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
|
||||
|
||||
echo "GNOME Remote Desktop RDP configured successfully"
|
||||
'';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ EOF
|
||||
"198.18.0.0/15" "198.51.100.0/24" "2001:db8::/32" "203.0.113.0/24"
|
||||
"224.0.0.0/4" "::1/128" "fc00::/7" "fe80::/10" "fec0::/10" "ff00::/8"
|
||||
];
|
||||
url_preview_ip_ranger_whitelist = [ "127.0.0.1" ];
|
||||
presence.enabled = true;
|
||||
enable_registration = false;
|
||||
listeners = [
|
||||
|
||||
@@ -31,6 +31,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
cat > "$CONFIG_FILE" << 'EOF'
|
||||
{
|
||||
"mode": "ONLINE",
|
||||
"serverType": "ELECTRUM_SERVER",
|
||||
"electrumServer": "tcp://127.0.0.1:50001",
|
||||
"useProxy": false
|
||||
@@ -42,42 +43,45 @@ EOF
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Bisq 1 Auto-Connect ─────────────────────────────────────
|
||||
systemd.services.bisq-autoconnect = {
|
||||
description = "Auto-configure Bisq to use local Bitcoin node";
|
||||
after = [ "bitcoind.service" ];
|
||||
# ── Zeus Connect (lndconnect URL for mobile wallet) ──────────
|
||||
systemd.services.zeus-connect-setup = {
|
||||
description = "Save Zeus lndconnect URL";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.iproute2 ];
|
||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
||||
script = ''
|
||||
BISQ_CONF="/home/free/.local/share/Bisq/bisq.properties"
|
||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
if [ -f "$BISQ_CONF" ]; then
|
||||
echo "Bisq config already exists, skipping"
|
||||
exit 0
|
||||
URL=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Wait for bitcoind RPC to be ready (up to 30 attempts)
|
||||
ATTEMPTS=0
|
||||
until ss -ltn 2>/dev/null | grep -q ':8333' || [ "$ATTEMPTS" -ge 30 ]; do
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
mkdir -p /home/free/.local/share/Bisq
|
||||
|
||||
cat > "$BISQ_CONF" << 'EOF'
|
||||
btcNodes=127.0.0.1:8333
|
||||
useTorForBtc=true
|
||||
useCustomBtcNodes=true
|
||||
EOF
|
||||
|
||||
chown -R free:users /home/free/.local/share/Bisq
|
||||
echo "Bisq auto-configured to use local Bitcoin node"
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Zeus connect URL saved."
|
||||
else
|
||||
echo "No lndconnect URL available yet."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Refresh Zeus URL periodically (certs/macaroons may rotate)
|
||||
systemd.timers.zeus-connect-setup = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "30min";
|
||||
Unit = "zeus-connect-setup.service";
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
# ── Fully automated WordPress setup ───────────────────────
|
||||
systemd.services.wordpress-init = {
|
||||
description = "Download, extract, and fully configure WordPress";
|
||||
after = [ "network-online.target" "mysql.service" "phpfpm-mypool.service" "wordpress-db-init.service" ];
|
||||
after = [ "network-online.target" "mysql.service" "phpfpm-wordpress.service" "wordpress-db-init.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
requires = [ "mysql.service" "wordpress-db-init.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -60,7 +60,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
path = with pkgs; [ curl unzip wp-cli pwgen php coreutils ];
|
||||
path = with pkgs; [ curl unzip wp-cli pwgen php coreutils shadow util-linux ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
@@ -73,7 +73,11 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
DB_HOST="localhost"
|
||||
ADMIN_USER=$(pwgen -s 16 1)
|
||||
ADMIN_PASS=$(pwgen -s 24 1)
|
||||
ADMIN_EMAIL="$ADMIN_USER@''${DOMAIN#*.}"
|
||||
EMAIL_DOMAIN="''${DOMAIN#*.}"
|
||||
if ! echo "$EMAIL_DOMAIN" | grep -q '\.'; then
|
||||
EMAIL_DOMAIN="$DOMAIN"
|
||||
fi
|
||||
ADMIN_EMAIL="$ADMIN_USER@$EMAIL_DOMAIN"
|
||||
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo " WordPress Automated Installation"
|
||||
@@ -90,14 +94,14 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
echo "Download complete."
|
||||
fi
|
||||
|
||||
chown -R caddy:root "$INSTALL_DIR"
|
||||
find "$INSTALL_DIR" -type d -exec chmod 755 {} \;
|
||||
find "$INSTALL_DIR" -type f -exec chmod 644 {} \;
|
||||
chmod -R 775 "$INSTALL_DIR/wp-content"
|
||||
chown -R caddy:php "$INSTALL_DIR"
|
||||
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
|
||||
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
|
||||
chmod -R 770 "$INSTALL_DIR/wp-content"
|
||||
|
||||
echo "Generating wp-config.php..."
|
||||
cd "$INSTALL_DIR"
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp config create \
|
||||
--dbname='$DB_NAME' \
|
||||
--dbuser='$DB_USER' \
|
||||
@@ -108,14 +112,14 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
|
||||
echo "Waiting for database..."
|
||||
for i in $(seq 1 30); do
|
||||
if su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then
|
||||
if /run/wrappers/bin/su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Running WordPress core install..."
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp core install \
|
||||
--url='https://$DOMAIN' \
|
||||
--title='Sovran_SystemsOS' \
|
||||
@@ -125,7 +129,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
--skip-email
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp option update blogdescription 'Powered by Sovran_SystemsOS'
|
||||
wp option update permalink_structure '/%postname%/'
|
||||
wp option update default_ping_status 'closed'
|
||||
@@ -133,7 +137,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
wp rewrite flush
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp config set DISALLOW_FILE_EDIT true --raw
|
||||
wp config set WP_AUTO_UPDATE_CORE true --raw
|
||||
wp config set FORCE_SSL_ADMIN true --raw
|
||||
@@ -158,9 +162,73 @@ CREDS
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.wordpress-detect-existing = {
|
||||
description = "Detect pre-existing WordPress installation and populate hub credentials";
|
||||
after = [ "mysql.service" ];
|
||||
wants = [ "mysql.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
unitConfig = {
|
||||
ConditionPathExists = [
|
||||
"/var/lib/www/wordpress/wp-config.php"
|
||||
"!/var/lib/secrets/wordpress-admin"
|
||||
];
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
path = with pkgs; [ coreutils gnused ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
CREDS_FILE="/var/lib/secrets/wordpress-admin"
|
||||
DOMAIN_FILE="/var/lib/domains/wordpress"
|
||||
DOMAIN="your-domain"
|
||||
|
||||
if [ -f "$DOMAIN_FILE" ]; then
|
||||
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
|
||||
if [ -n "$FILE_DOMAIN" ]; then
|
||||
DOMAIN="$FILE_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
cat > "$CREDS_FILE" << CREDS
|
||||
WordPress (Pre-existing Installation)
|
||||
═══════════════════════════════════════
|
||||
URL: https://$DOMAIN/wp-admin/
|
||||
Note: This WordPress was installed before Sovran_SystemsOS.
|
||||
Use your existing admin credentials to log in.
|
||||
Reset: wp user update <username> --user_pass=<new-password>
|
||||
CREDS
|
||||
chmod 600 "$CREDS_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
services.phpfpm.pools.wordpress = {
|
||||
user = "caddy";
|
||||
group = "php";
|
||||
phpPackage = config.sovran_systemsOS.phpPackage;
|
||||
settings = {
|
||||
"pm" = "dynamic";
|
||||
"pm.max_children" = 75;
|
||||
"pm.start_servers" = 10;
|
||||
"pm.min_spare_servers" = 5;
|
||||
"pm.max_spare_servers" = 20;
|
||||
"pm.max_requests" = 500;
|
||||
"clear_env" = "no";
|
||||
"listen" = "/run/phpfpm/wordpress.sock";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/www 0755 caddy root -"
|
||||
"d /var/lib/www/wordpress 0755 caddy root -"
|
||||
"d /var/lib/www/wordpress 0750 caddy php -"
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [ wp-cli unzip ];
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS — First-Boot Setup</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ style_css_hash }}" />
|
||||
</head>
|
||||
<body class="onboarding-body">
|
||||
|
||||
<!-- Onboarding wizard container -->
|
||||
<div class="onboarding-shell">
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="onboarding-progress-bar">
|
||||
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="onboarding-steps-nav" id="onboarding-steps-nav">
|
||||
<span class="onboarding-step-dot" data-step="1">1</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="2">2</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="3">3</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||
</div>
|
||||
|
||||
<!-- Step panels -->
|
||||
<div class="onboarding-panel-wrap">
|
||||
|
||||
<!-- ── Step 1: Welcome ── -->
|
||||
<div class="onboarding-panel" id="step-1">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">
|
||||
<img src="/static/logo-light.svg" alt="Sovran Systems" class="onboarding-logo-img" />
|
||||
</div>
|
||||
<h1 class="onboarding-title">Welcome to Sovran_SystemsOS!</h1>
|
||||
<p class="onboarding-subtitle">Be Digitally Sovereign</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text">
|
||||
Your system is installed and ready to configure. This wizard will guide
|
||||
you through the final setup steps so everything works perfectly.
|
||||
</p>
|
||||
<div class="onboarding-role-row" id="onboarding-role-row">
|
||||
<span class="onboarding-role-label">Your Role:</span>
|
||||
<span class="onboarding-role-badge" id="onboarding-role-badge">Loading…</span>
|
||||
</div>
|
||||
<p class="onboarding-body-text onboarding-body-text--dim">
|
||||
This setup only takes a few minutes. You can always revisit these
|
||||
settings from the main Hub dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<div></div>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-1-next">
|
||||
Let's Go →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Domain Configuration ── -->
|
||||
<div class="onboarding-panel" id="step-2" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
||||
First, create an account at <strong>Njal.la</strong> and purchase your domain.
|
||||
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
|
||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card onboarding-card--scroll" id="step-2-body">
|
||||
<p class="onboarding-loading">Loading service information…</p>
|
||||
</div>
|
||||
<div id="step-2-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
|
||||
Save & Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 3: Port Forwarding ── -->
|
||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
|
||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card onboarding-card--ports" id="step-3-body">
|
||||
<p class="onboarding-loading">Checking ports…</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">✅</div>
|
||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||
<p class="onboarding-subtitle">Setup complete</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text">
|
||||
All configuration steps are done. Head to the main Hub dashboard to
|
||||
monitor your services, manage credentials, and make changes at any time.
|
||||
</p>
|
||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||
<li>✅ Domain configuration saved</li>
|
||||
<li>✅ Port forwarding reviewed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary" id="step-4-finish">
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /panel-wrap -->
|
||||
</div><!-- /shell -->
|
||||
|
||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||
</body>
|
||||
</html>
|
||||