Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a97cb1adba | ||
|
|
210cef99f9 | ||
|
|
1732bb0a2f | ||
|
|
68b86bdf49 | ||
|
|
e294a4057d | ||
|
|
b98c82886f | ||
|
|
e16eaabdde | ||
|
|
0fa804a430 | ||
|
|
01db44f0d2 | ||
|
|
978a82ade3 | ||
|
|
c202c69898 | ||
|
|
d1251bf238 | ||
|
|
de25110493 | ||
|
|
ab31f4f60b | ||
|
|
f5a5e99de4 | ||
|
|
a30d4d5e74 | ||
|
|
12b8872847 | ||
|
|
56e0c4dcd2 | ||
|
|
22aa251d64 | ||
|
|
35c4de412f | ||
|
|
16067be909 | ||
|
|
36abece7f9 | ||
|
|
36187c0504 | ||
|
|
992c806ed7 | ||
|
|
9f3d3e7670 | ||
|
|
0ec8203557 | ||
|
|
ab4de8da7d | ||
|
|
92cf417760 | ||
|
|
ec4c1c851b | ||
|
|
38f49e9161 | ||
|
|
c853853616 | ||
|
|
3e2bc106b1 | ||
|
|
d4f8c7b431 | ||
|
|
dcbac4760f | ||
|
|
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 | ||
|
|
8a3b5c031f | ||
|
|
b441515f89 | ||
|
|
b7c1632bb8 | ||
|
|
a7d40fb138 | ||
|
|
8f4ec83104 | ||
|
|
fba4ab13cf | ||
|
|
5ba1a256fe | ||
|
|
5ee0ef4d58 | ||
|
|
5a77a03ac0 | ||
|
|
5349c2408a | ||
|
|
9ea4fb32f4 | ||
|
|
9e1673ef7f | ||
|
|
44a7b2a8ab | ||
|
|
aa24505314 | ||
|
|
d0bf878555 | ||
|
|
2e5be9816e | ||
|
|
87ecccff9e | ||
|
|
d60a44b438 | ||
|
|
9413bb6403 | ||
|
|
28bcddb957 | ||
|
|
1737e93c68 | ||
|
|
90fdbdea70 | ||
|
|
90ffadf2ea | ||
|
|
f6c9080cea | ||
|
|
8e40faad75 | ||
|
|
4978d44ba2 | ||
|
|
6fefed2909 | ||
|
|
1a48266cde | ||
|
|
639d39108a | ||
|
|
185ed4e3d8 | ||
|
|
8240b9af3c | ||
|
|
deb66c9cb7 | ||
|
|
8a49a3d04e | ||
|
|
cdccb8138c | ||
|
|
09a817f02d | ||
|
|
4edcf066ca |
@@ -6,3 +6,6 @@ role-state.nix
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
iso/secrets/enroll-token
|
||||||
|
iso/secrets/provisioner-url
|
||||||
|
result
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Created with Vectornator (http://vectornator.io/) -->
|
||||||
|
<svg stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="8.789 8.791 32.422 32.418" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="Untitled">
|
||||||
|
<path d="M38.8519 23.6353L38.3818 24.2217L41.2105 25.0446L41.2105 24.8132L39.3844 23.4968L38.8519 23.6353Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M37.9843 25.2648L38.7188 25.2376L40.119 25.1551L41.2106 25.0446L38.3819 24.2217L37.5381 23.9764L37.9843 25.2648Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M38.8519 23.6353L38.3818 24.2217L37.5381 23.9764L38.0403 23.846L38.8519 23.6353Z" fill="#ababab" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M37.9843 25.2648L33.5662 25.2648L36.0929 24.3388L37.9843 25.2648Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M38.0403 23.846L37.145 23.3355L36.8724 23.18L38.7803 21.9829L39.3844 23.4968L38.8519 23.6353L38.0403 23.846Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M37.0092 23.5521L36.7613 23.8298L36.3913 23.3554L36.3959 23.3517L37.0092 23.5521Z" fill="#ffffff" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.8724 23.1801L37.1451 23.3355L37.0092 23.5521L36.3959 23.3517L36.6823 23.1455L36.8724 23.1801Z" fill="#ffffff" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M38.7803 21.9829L36.8724 23.18L36.2058 20.8919L38.7665 21.949L38.7803 21.9829Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.7613 23.8298L37.0092 23.5521L37.1451 23.3355L38.0403 23.846L37.5381 23.9764L36.7172 23.8792L36.7613 23.8298Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.3913 23.3554L36.7613 23.8298L36.7172 23.8792L35.9653 23.6626L36.3913 23.3554Z" fill="#808080" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.7172 23.8792L37.5381 23.9764L36.103 24.3351L36.069 24.3278L36.0681 24.3271L33.4046 23.7318L35.9653 23.6626L36.7172 23.8792Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.3958 23.3517L36.3913 23.3554L36.1535 23.0497L36.6823 23.1455L36.3958 23.3517Z" fill="#efefef" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.1535 23.0497L36.3913 23.3554L35.9653 23.6626L35.9139 23.2419L36.1535 23.0497Z" fill="#d4d4d4" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.2058 20.8919L36.8724 23.1801L36.6823 23.1454L36.1535 23.0497L35.6779 22.9627L36.1342 20.8956L36.2058 20.8919Z" fill="#d8d8d8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.1342 20.8956L35.6779 22.9627L34.4972 20.9745L36.1342 20.8956Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.103 24.3351L37.5381 23.9764L37.9843 25.2648L36.0929 24.3388L36.103 24.3351Z" fill="#d8d8d8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M36.0928 24.3388L33.5661 25.2648L33.5092 25.2648L33.3183 23.734L33.4046 23.7318L36.0681 24.3271L36.069 24.3278L36.0928 24.3388Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M33.5092 25.2648L35.7366 27.2015L35.3005 27.5949L35.2785 27.6664L35.2638 27.6745L31.5591 26.1025L33.5092 25.2648Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M34.4972 20.9745L35.6779 22.9627L33.3202 23.7046L34.4972 20.9745Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M35.2748 27.6789L34.8397 29.0874L34.0712 31.1582L31.3848 29.8823L35.2639 27.6745L35.2748 27.6789Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M35.2638 27.6745L31.3847 29.8823L31.5591 26.1025L35.2638 27.6745Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M33.2366 23.6942L31.0303 23.4143L32.3322 22.3697L32.705 22.0706L34.4972 20.9744L33.3202 23.7045L33.2366 23.6942Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M34.0712 31.1582L33.9959 31.2459L33.995 31.2459L29.0407 31.307L29.0398 31.307L29.0306 31.2916L29.0003 31.2399L31.3848 29.8823L34.0712 31.1582Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M33.9949 31.2459L32.3276 33.1745L30.6869 33.9126L29.0407 31.307L33.9949 31.2459Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M33.5661 25.2648L37.9842 25.2648L37.112 25.8822L36.5152 26.4988L35.7366 27.2015L33.5092 25.2648L33.5661 25.2648Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M33.3183 23.734L33.5092 25.2648L31.5591 26.1024L31.0303 23.4143L33.2366 23.6942L33.3155 23.7119L33.3183 23.734Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M33.3183 23.734L33.4046 23.7318L33.3156 23.712L33.3183 23.734Z" fill="#3a3a3a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M35.6779 22.9627L36.1535 23.0497L35.9139 23.2419L35.9653 23.6626L33.4046 23.7318L33.3156 23.7119L33.2366 23.6942L33.3202 23.7046L35.6779 22.9627Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<g opacity="1">
|
||||||
|
<path d="M34.0372 36.0387C34.0712 36.1212 34.0023 36.4077 34.0023 36.4077L33.5773 36.8313L33.6938 36.4622L33.8655 36.1594L33.6598 35.7359L32.671 35.3941L32.9253 35.7904L33.1998 36.4077L32.8904 36.6L32.4305 37.0795L32.5682 36.628L32.8904 36.2972C32.8904 36.2972 32.705 35.9834 32.671 35.8729C32.6361 35.7631 32.0394 35.3661 32.0394 35.3661L31.8677 35.1186L32.8106 35.0124L32.8115 35.0124L34.14 35.3116L34.14 35.8729L33.9683 35.5038L33.3706 35.3388L33.8655 35.6526C33.8655 35.6526 34.0023 35.9554 34.0372 36.0387" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M32.8106 35.0124L31.8677 35.1186L30.2958 34.3251L32.4994 34.9425L32.8106 35.0124Z" fill="#7f7f7f" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.9016 15.918L32.4305 17.3559L30.3894 21.9646L29.6981 23.5248L29.9561 20.9605L30.0994 19.5358L30.2352 18.1869L30.2958 18.2348L30.2728 18.1354L31.9016 15.918Z" fill="#585858" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M32.4305 17.3559L31.9016 20.8647L31.0303 23.4143L30.3894 21.9645L32.4305 17.3559Z" fill="#545454" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.9016 20.8647L32.3322 22.3697L31.0303 23.4143L31.9016 20.8647Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.9016 15.918L30.2728 18.1354L29.89 16.4808L29.89 16.4801L29.935 14.4225C30.3986 14.7769 30.6943 15.0111 30.7219 15.0539C30.8724 15.2851 31.9016 15.918 31.9016 15.918" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.8677 35.1186L30.8935 34.9145L29.5613 34.4467L30.2958 34.3251L31.8677 35.1186Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.5592 26.1024L31.3848 29.8823L27.8912 29.3739L27.1521 28.1289L31.5142 26.123L31.5592 26.1024Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.4894 26.0891L31.5141 26.123L27.1521 28.1289L29.3363 26.07L29.3896 26.0199L31.4894 26.0891Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.3848 29.8823L29.0003 31.2399L27.8912 29.374L31.3848 29.8823Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.7898 36.0659L30.9275 35.5428L31.8677 35.1186L32.0394 35.3661L31.1331 35.6806L31.0303 36.0107L31.202 36.4077L30.7898 36.0659Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.3894 21.9645L31.0303 23.4143L29.6981 23.5248L30.3894 21.9645Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.5282 24.895L29.6981 23.5248L31.0303 23.4143L29.3987 25.9462L29.5282 24.895Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.6869 33.9126L30.2958 34.3251L29.5613 34.4467L28.6496 34.0754L30.6869 33.9126Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.159 37.7234L29.7669 37.162L29.2179 36.804L30.7181 37.0648L30.9615 37.2445C30.9615 37.2445 31.236 37.5584 31.2709 37.6961C31.3048 37.8339 31.3737 38.1477 31.3737 38.2582C31.3737 38.368 30.8247 38.7098 30.8247 38.7098L31.0992 38.396L30.9275 37.7234L30.2269 37.327C30.2269 37.327 30.5703 38.038 30.6043 38.1477C30.6392 38.2582 30.4675 38.5168 30.3298 38.5448C30.1976 38.5713 29.7936 38.8564 29.7679 38.874L30.2958 38.2582L30.159 37.7234Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.7181 37.0648L29.2179 36.804L29.127 36.1167L29.1353 36.1211L29.5952 36.3252L30.3298 36.7768L30.7181 37.0648Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.2352 18.1869L30.0994 19.5358L22.4568 15.5216L20.4039 14.2052L26.5673 16.175L30.181 18.1449L30.1828 18.1456L30.2352 18.1869Z" fill="#9c9c9c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.181 18.1449L26.5673 16.175L22.5045 13.9599L20.9189 12.4372L20.2533 11.7653L20.2533 11.3137L20.6444 11.0935L22.353 11.9966L30.181 18.1449Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M30.0993 19.5358L29.9561 20.9605L23.1912 17.2491L23.1224 17.2344L20.9189 15.918L20.816 15.2741L21.1383 15.0811L22.4567 15.5216L30.0993 19.5358Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.935 14.4225L29.89 16.4801L29.89 16.4808L29.8881 16.5773L22.4788 10.1284L29.935 14.4225Z" fill="#9c9c9c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.935 14.4225L22.4788 10.1284L21.6874 9.07799L21.9417 8.79141L22.5596 8.95646C22.5596 8.95646 27.8866 12.8571 29.935 14.4225" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M22.5247 10.1904L22.4789 10.1284L29.8882 16.5773L29.89 16.4808L30.2728 18.1353L30.2352 18.1869L30.1829 18.1457L30.181 18.1449L22.3531 11.9966L21.0218 10.5042L20.884 9.97009L21.3789 9.63932L22.5247 10.1904Z" fill="#585858" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.6871 23.5197L23.9258 19.2617L23.1564 18.6171L22.7598 18.069L22.7607 18.069L29.9561 20.9605L29.6981 23.5248L29.6871 23.5197Z" fill="#545454" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.6981 23.5248L29.5282 24.895L26.0605 21.9771L29.6871 23.5197L29.6981 23.5248Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.6871 23.5197L26.0605 21.9771L24.1452 20.3851L23.5485 19.658L23.5135 19.1792L23.9258 19.2617L29.6871 23.5197Z" fill="#3a3a3a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M31.4894 26.0891L29.3896 26.0199L29.3987 25.9462L31.0303 23.4143L31.5592 26.1024L31.5141 26.123L31.4894 26.0891Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.3896 26.0199L29.3364 26.07L29.2399 26.067L29.3896 26.0199Z" fill="#a3a3a3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.3896 26.0199L29.2399 26.067L23.1637 25.8792L24.8265 22.8566L29.3896 26.0199Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.3363 26.07L27.152 28.129L24.237 29.1773L24.0626 29.0491L22.7451 28.9386L22.1823 28.6955L25.8198 27.1434L29.2399 26.0671L29.3363 26.07Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.2399 26.0671L25.8199 27.1434L23.1637 25.88L23.1637 25.8792L29.2399 26.0671Z" fill="#c8c8c8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M28.9985 36.4902L28.118 35.5996L29.127 36.1167L29.2179 36.804L28.8268 37.2998L28.7579 37.7786L29.1353 38.3407L28.5862 38.01L28.4357 37.3661L28.6551 36.997L28.9985 36.4902Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.1261 36.1108L29.127 36.1167L28.118 35.5996L28.0234 34.832L29.1261 36.1108Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M28.6147 34.0769L28.5743 34.071L29.0398 31.307L29.0407 31.307L30.6869 33.9126L28.6496 34.0754L28.6211 34.0776L28.6156 34.0769L28.6147 34.0769Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.0398 31.307L28.5743 34.071L22.8819 33.268L29.0398 31.307Z" fill="#a2a2a2" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.0398 31.307L22.8819 33.268L26.2331 30.0856L29.0398 31.307Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M29.0398 31.307L26.2331 30.0856L27.8912 29.374L29.0003 31.2399L28.9984 31.2407L29.0306 31.2916L29.0398 31.307Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M28.6211 34.0776L28.6496 34.0754L29.5613 34.4467L28.0234 34.832L28.6211 34.0776Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M28.6211 34.0776L28.0234 34.832L28.118 35.5996L28.0234 35.5038L27.2889 34.9425L28.6211 34.0776Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M28.6211 34.0776L27.2889 34.9425L26.8639 35.0361L25.9411 34.0864L28.5735 34.0769L28.6147 34.0769L28.6156 34.0769L28.6211 34.0776Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M28.5743 34.071L28.5734 34.0769L25.9411 34.0864L25.9383 34.0864L23.2858 34.0953L23.2849 34.0953L22.8819 33.268L28.5743 34.071Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M27.1521 28.129L27.8912 29.374L26.2331 30.0856L27.1521 28.129Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M26.0862 35.1288L26.8639 35.036C26.8639 35.036 24.7972 38.9285 24.3619 39.7816L26.0862 35.1288Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M22.5045 13.9599L26.5673 16.175L20.4039 14.2052L20.1156 13.6711L20.2533 13.2858L20.7472 13.2858L22.5596 14.0129L22.5045 13.9599Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M26.2331 30.0856L22.8819 33.268L23.5135 31.1309L24.2232 29.2148L24.2407 29.2192L26.2331 30.0856Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M26.0862 35.1288L24.3619 39.7816C24.3142 39.8759 24.2867 39.9326 24.283 39.9437C24.2481 40.0535 23.5485 40.5603 23.5485 40.5603L22.848 40.5876L26.0862 35.1288Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M25.9383 34.0864L25.9411 34.0864L26.8638 35.0361L26.0861 35.1288L24.5565 35.3116L25.9383 34.0864Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M25.9383 34.0864L24.5565 35.3116L24.1076 35.0559L23.6164 34.7767L23.2849 34.0961L23.2858 34.0953L25.9383 34.0864Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M25.8199 27.1434L22.1823 28.6955L22.1814 28.6955L22.0445 28.6358L21.5368 28.1784L23.1637 25.88L25.8199 27.1434Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.4198 22.5745L24.8265 22.8566L21.0998 22.6128L19.7033 22.5214L19.6308 22.506L16.4063 19.8805L22.6845 21.415L24.4198 22.5745Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.5565 35.3116L22.69 40.7334C22.5403 40.8661 22.3227 41.0399 22.2162 41.0399C22.0445 41.0399 21.1732 41.1769 21.1732 41.1769L20.8014 41.1865L24.5565 35.3116Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.5565 35.3116L20.8014 41.1865L19.986 41.2086L19.3121 41.1496L19.1809 40.9906L24.1076 35.0559L24.5565 35.3116Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.0552 20.3608L24.4197 22.5745L22.6844 21.415L19.0744 19.0024L24.0552 20.3608Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.2407 29.2192L24.2233 29.2148L24.237 29.1772L27.1521 28.1289L26.2331 30.0856L24.2407 29.2192Z" fill="#8d8d8d" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.1452 20.3852L26.0605 21.9771L29.5283 24.895L29.3988 25.9463L29.3896 26.02L24.8265 22.8567L24.4198 22.5745L24.0552 20.3608L24.1452 20.3852Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.1076 35.0559L19.1809 40.9905L18.5777 41.0399L17.8092 40.8078L17.5347 40.2848L24.1076 35.0559Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.1076 35.0559L17.5347 40.2848L23.2849 34.0961L23.6164 34.7767L24.1076 35.0559Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M23.2858 34.0953L23.2849 34.0961L23.2849 34.0953L23.2858 34.0953Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M23.2849 34.0953L23.2849 34.0961L16.3603 39.3853L19.0036 36.8313L22.8819 33.268L23.2849 34.0953Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M23.2849 34.0961L17.5346 40.2848L16.869 40.3673L16.2373 40.0535L16.3062 39.4369L16.3585 39.3861L16.3603 39.3853L23.2849 34.0961Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M24.8265 22.8567L23.1637 25.8792L23.1628 25.8792L23.138 25.8674L21.0998 22.6128L24.8265 22.8567Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M23.1628 25.8792L23.1637 25.8792L23.1637 25.88L21.5368 28.1784L21.4817 28.129L20.4039 28.157L19.9788 27.9368L19.5187 27.5677L19.1754 27.1434L19.2699 27.1132L23.1628 25.8792Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M23.138 25.8674L23.1628 25.8792L19.2698 27.1132L20.3441 24.5385L23.138 25.8674Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M22.8479 40.5876C22.8479 40.5876 22.7818 40.6524 22.69 40.7334L24.5565 35.3116L26.0861 35.1288L22.8479 40.5876Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M22.7102 18.0005L22.6762 17.2071L23.1224 17.2344L23.1913 17.2491L29.9561 20.9605L22.7606 18.0691L22.7598 18.0691L22.7102 18.0005Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M19.0744 19.0024L22.6844 21.415L16.4063 19.8805L11.2674 17.3287L19.0734 19.0024L19.0744 19.0024Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M20.3442 24.5385L20.256 24.5385L14.6995 24.5385C14.6995 24.5385 12.7365 23.6626 12.7016 23.5794C12.6676 23.4969 12.3591 22.9628 12.3591 22.9628L12.4271 22.6048L17.9763 23.6118L18.0745 23.6295L20.3442 24.5385Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M20.3442 24.5385L19.2699 27.1132L19.1754 27.1434L18.3381 27.0881L17.6724 26.8958L17.2464 26.526L17.0885 26.0104L20.256 24.5385L20.3442 24.5385Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M20.256 24.5385L17.0884 26.0104L17.0747 25.9647L15.2971 25.7717L14.8712 25.2376L14.6995 24.5385L20.256 24.5385Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M16.4063 19.8805L19.6307 22.506L19.5114 22.4802L11.24 19.1166L10.0381 18.3305L9.06393 17.3007L8.82343 16.673L8.99512 16.3703L9.86642 16.7666L11.2675 17.3287L16.4063 19.8805Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M17.9763 23.6117L12.4271 22.6047L10.8552 22.2356L19.2993 22.4353L19.5114 22.4802L19.6307 22.506L19.7033 22.5214L17.9763 23.6117Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M17.9763 23.6117L19.7033 22.5214L21.0998 22.6128L23.1381 25.8674L20.3442 24.5385L18.0745 23.6294L17.9763 23.6117Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M19.0735 19.0024L11.2675 17.3287L9.45504 15.5216L9.38617 15.0538L9.62673 14.8226L12.4959 16.5625C12.4959 16.5625 16.2373 18.0278 16.443 18.1383C16.6487 18.248 18.1664 18.7549 18.1664 18.7549L19.0735 19.0024Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M19.2993 22.4353L11.463 20.7601L11.542 20.7704L9.59278 19.9608L8.99511 19.2617L8.78939 18.6724L9.06398 18.4411L11.3354 19.1792L11.24 19.1166L19.5114 22.4802L19.2993 22.4353Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
<path d="M11.4621 20.7601L11.4631 20.7601L19.2993 22.4353L10.8552 22.2357L10.073 21.5918L9.79847 20.8367L9.90129 20.5502L11.4621 20.7601Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -1,66 +1,259 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?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/">
|
<svg
|
||||||
<defs id="defs387" />
|
version="1.1"
|
||||||
<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" />
|
viewBox="0 0 256 256"
|
||||||
<title id="title287">Vaultwarden Icon</title>
|
id="svg383"
|
||||||
<g id="logo" transform="matrix(2.4381018,0,0,2.4381018,128,128)">
|
sodipodi:docname="vaultwarden.svg"
|
||||||
<g id="gear" mask="url(#holes)">
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
<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" />
|
width="256"
|
||||||
<circle transform="scale(-1,1)" r="43" fill="none" stroke="#000" stroke-width="9" id="circle291" />
|
height="256"
|
||||||
<g id="cogs" transform="scale(-1,1)">
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
<polygon id="cog" points="51 0 46 -3 46 3" stroke="#000" stroke-linejoin="round" stroke-width="3" />
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
<use transform="rotate(11.25)" xlink:href="#cog" id="use294" />
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
<use transform="rotate(22.5)" xlink:href="#cog" id="use296" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<use transform="rotate(33.75)" xlink:href="#cog" id="use298" />
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
<use transform="rotate(45)" xlink:href="#cog" id="use300" />
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
<use transform="rotate(56.25)" xlink:href="#cog" id="use302" />
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
<use transform="rotate(67.5)" xlink:href="#cog" id="use304" />
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
<use transform="rotate(78.75)" xlink:href="#cog" id="use306" />
|
<defs
|
||||||
<use transform="rotate(90)" xlink:href="#cog" id="use308" />
|
id="defs387">
|
||||||
<use transform="rotate(101.25)" xlink:href="#cog" id="use310" />
|
<mask
|
||||||
<use transform="rotate(112.5)" xlink:href="#cog" id="use312" />
|
id="holes">
|
||||||
<use transform="rotate(123.75)" xlink:href="#cog" id="use314" />
|
<rect
|
||||||
<use transform="rotate(135)" xlink:href="#cog" id="use316" />
|
x="-60"
|
||||||
<use transform="rotate(146.25)" xlink:href="#cog" id="use318" />
|
y="-60"
|
||||||
<use transform="rotate(157.5)" xlink:href="#cog" id="use320" />
|
width="120"
|
||||||
<use transform="rotate(168.75)" xlink:href="#cog" id="use322" />
|
height="120"
|
||||||
<use transform="scale(-1)" xlink:href="#cog" id="use324" />
|
fill="#fff"
|
||||||
<use transform="rotate(191.25)" xlink:href="#cog" id="use326" />
|
id="rect368" />
|
||||||
<use transform="rotate(202.5)" xlink:href="#cog" id="use328" />
|
<circle
|
||||||
<use transform="rotate(213.75)" xlink:href="#cog" id="use330" />
|
id="hole"
|
||||||
<use transform="rotate(225)" xlink:href="#cog" id="use332" />
|
cy="-40"
|
||||||
<use transform="rotate(236.25)" xlink:href="#cog" id="use334" />
|
r="3" />
|
||||||
<use transform="rotate(247.5)" xlink:href="#cog" id="use336" />
|
<use
|
||||||
<use transform="rotate(258.75)" xlink:href="#cog" id="use338" />
|
transform="rotate(72)"
|
||||||
<use transform="rotate(-90)" xlink:href="#cog" id="use340" />
|
xlink:href="#hole"
|
||||||
<use transform="rotate(-78.75)" xlink:href="#cog" id="use342" />
|
id="use371" />
|
||||||
<use transform="rotate(-67.5)" xlink:href="#cog" id="use344" />
|
<use
|
||||||
<use transform="rotate(-56.25)" xlink:href="#cog" id="use346" />
|
transform="rotate(144)"
|
||||||
<use transform="rotate(-45)" xlink:href="#cog" id="use348" />
|
xlink:href="#hole"
|
||||||
<use transform="rotate(-33.75)" xlink:href="#cog" id="use350" />
|
id="use373" />
|
||||||
<use transform="rotate(-22.5)" xlink:href="#cog" id="use352" />
|
<use
|
||||||
<use transform="rotate(-11.25)" xlink:href="#cog" id="use354" />
|
transform="rotate(216)"
|
||||||
</g>
|
xlink:href="#hole"
|
||||||
<g id="mounts" transform="scale(-1,1)">
|
id="use375" />
|
||||||
<polygon id="mount" points="0 -35 7 -42 -7 -42" stroke="#000" stroke-linejoin="round" stroke-width="6" />
|
<use
|
||||||
<use transform="rotate(72)" xlink:href="#mount" id="use358" />
|
transform="rotate(-72)"
|
||||||
<use transform="rotate(144)" xlink:href="#mount" id="use360" />
|
xlink:href="#hole"
|
||||||
<use transform="rotate(216)" xlink:href="#mount" id="use362" />
|
id="use377" />
|
||||||
<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" />
|
|
||||||
</mask>
|
</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>
|
</g>
|
||||||
<metadata id="metadata381">
|
<metadata
|
||||||
|
id="metadata381">
|
||||||
<rdf:RDF>
|
<rdf:RDF>
|
||||||
<cc:Work rdf:about="">
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
<dc:title>Vaultwarden Icon</dc:title>
|
<dc:title>Vaultwarden Icon</dc:title>
|
||||||
<dc:creator>
|
<dc:creator>
|
||||||
<cc:Agent>
|
<cc:Agent>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ── Sovran Hub External Backup Script ────────────────────────────
|
# ── Sovran Hub External Backup Script ────────────────────────────
|
||||||
# Backs up Sovran_SystemsOS data to an external USB hard drive.
|
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
||||||
# Designed for the Hub web UI (no GUI dependencies).
|
# Designed for the Hub web UI (no GUI dependencies).
|
||||||
#
|
#
|
||||||
# Your Sovran Pro already backs up your data automatically to its
|
# Your Sovran Pro already backs up your data automatically to its
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
# This script creates an additional copy on an external USB drive —
|
# This script creates an additional copy on an external USB drive —
|
||||||
# storing your data in a third location for maximum protection.
|
# storing your data in a third location for maximum protection.
|
||||||
#
|
#
|
||||||
|
# The external drive must be formatted as ext4. Files are stored as
|
||||||
|
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
||||||
|
# Later runs update the same mirror and only transfer changed or new
|
||||||
|
# files, making repeat backups fast.
|
||||||
|
#
|
||||||
|
# PostgreSQL and MariaDB/MySQL databases are NOT included. Bitcoin
|
||||||
|
# blockchain and Electrs index data are NOT included (they live on
|
||||||
|
# the internal second drive).
|
||||||
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
||||||
# (or run with no env var to auto-detect the first external USB drive)
|
# (or run with no env var to auto-detect the first external USB drive)
|
||||||
@@ -17,18 +26,28 @@ set -euo pipefail
|
|||||||
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
||||||
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
||||||
MEDIA_ROOT="/run/media"
|
MEDIA_ROOT="/run/media"
|
||||||
MIN_FREE_GB=10
|
|
||||||
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
||||||
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
||||||
|
SECOND_DRIVE_MOUNT="/run/media/Second_Drive"
|
||||||
|
SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024))
|
||||||
|
|
||||||
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
||||||
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
||||||
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
|
INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/")
|
||||||
|
|
||||||
|
FAILED_ALREADY=0
|
||||||
|
BACKUP_COMPLETE=0
|
||||||
|
RSYNC_WARNINGS=()
|
||||||
|
|
||||||
|
# Stable rsync mirror sub-path under the target drive. Not timestamped
|
||||||
|
# so later runs update the same destination and only transfer new or changed files.
|
||||||
|
BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current"
|
||||||
|
|
||||||
# ── Logging helpers ──────────────────────────────────────────────
|
# ── Logging helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
local msg
|
||||||
|
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
echo "$msg" | tee -a "$BACKUP_LOG"
|
echo "$msg" | tee -a "$BACKUP_LOG"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,16 +56,47 @@ set_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
|
FAILED_ALREADY=1
|
||||||
log "ERROR: $*"
|
log "ERROR: $*"
|
||||||
set_status "FAILED"
|
set_status "FAILED"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
# Release the concurrency lock file descriptor if it was opened
|
||||||
|
if [[ -n "${LOCK_FD:-}" ]]; then
|
||||||
|
exec {LOCK_FD}>&- 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
|
||||||
|
log "ERROR: Backup terminated unexpectedly (exit code $rc)."
|
||||||
|
set_status "FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mark the backup directory as incomplete so failed runs are identifiable
|
||||||
|
if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then
|
||||||
|
touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 1' INT TERM
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Check whether a mount point is an internal drive ────────────
|
# ── Check whether a mount point is an internal drive ────────────
|
||||||
|
|
||||||
is_internal() {
|
is_internal() {
|
||||||
local mnt="$1"
|
local mnt="$1"
|
||||||
# Reject known internal mount points and their subdirectories
|
|
||||||
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
||||||
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
||||||
return 0
|
return 0
|
||||||
@@ -59,39 +109,37 @@ is_internal() {
|
|||||||
|
|
||||||
find_external_drive() {
|
find_external_drive() {
|
||||||
local target=""
|
local target=""
|
||||||
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
|
|
||||||
if command -v lsblk &>/dev/null; then
|
|
||||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
|
||||||
# Must be a partition or disk, and be removable/hotplug
|
|
||||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
|
||||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
|
||||||
[[ -n "$mountpoint" ]] || continue
|
|
||||||
|
|
||||||
# Filter out internal labels
|
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||||
local skip=0
|
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
[[ -n "$mountpoint" ]] || continue
|
||||||
done
|
|
||||||
[[ "$skip" -eq 1 ]] && continue
|
|
||||||
|
|
||||||
# Filter out internal mount points
|
local skip=0
|
||||||
is_internal "$mountpoint" && continue
|
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||||
|
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||||
|
done
|
||||||
|
[[ "$skip" -eq 1 ]] && continue
|
||||||
|
|
||||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
is_internal "$mountpoint" && continue
|
||||||
target="$mountpoint"
|
|
||||||
break
|
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||||
fi
|
target="$mountpoint"
|
||||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
break
|
||||||
python3 -c "
|
fi
|
||||||
|
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||||
|
python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
data = json.load(sys.stdin)
|
|
||||||
def flatten(devs):
|
def flatten(devs):
|
||||||
for d in devs:
|
for d in devs:
|
||||||
yield d
|
yield d
|
||||||
for c in d.get('children', []):
|
for c in d.get('children', []):
|
||||||
yield from flatten([c])
|
yield from flatten([c])
|
||||||
|
|
||||||
|
data = json.load(sys.stdin)
|
||||||
for d in flatten(data.get('blockdevices', [])):
|
for d in flatten(data.get('blockdevices', [])):
|
||||||
print('\t'.join([
|
print('\\t'.join([
|
||||||
d.get('type') or '',
|
d.get('type') or '',
|
||||||
str(d.get('hotplug') or '0'),
|
str(d.get('hotplug') or '0'),
|
||||||
str(d.get('rm') or '0'),
|
str(d.get('rm') or '0'),
|
||||||
@@ -99,24 +147,10 @@ for d in flatten(data.get('blockdevices', [])):
|
|||||||
d.get('mountpoint') or '',
|
d.get('mountpoint') or '',
|
||||||
]))
|
]))
|
||||||
" 2>/dev/null || true)
|
" 2>/dev/null || true)
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback: walk /run/media/ if lsblk produced nothing
|
|
||||||
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
||||||
while IFS= read -r -d '' mnt; do
|
while IFS= read -r -d '' mnt; do
|
||||||
is_internal "$mnt" && continue
|
is_internal "$mnt" && continue
|
||||||
# Check label via lsblk on the device backing this mount
|
|
||||||
local dev
|
|
||||||
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
|
|
||||||
if [[ -n "$dev" ]]; then
|
|
||||||
local lbl
|
|
||||||
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
|
|
||||||
local skip=0
|
|
||||||
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
|
|
||||||
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
|
|
||||||
done
|
|
||||||
[[ "$skip" -eq 1 ]] && continue
|
|
||||||
fi
|
|
||||||
if mountpoint -q "$mnt" 2>/dev/null; then
|
if mountpoint -q "$mnt" 2>/dev/null; then
|
||||||
target="$mnt"
|
target="$mnt"
|
||||||
break
|
break
|
||||||
@@ -128,16 +162,10 @@ for d in flatten(data.get('blockdevices', [])):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── Detect the configured system role ───────────────────────────
|
# ── Detect the configured system role ───────────────────────────
|
||||||
#
|
|
||||||
# Priority:
|
|
||||||
# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key
|
|
||||||
# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag
|
|
||||||
# 3. Default: server_plus_desktop
|
|
||||||
|
|
||||||
detect_role() {
|
detect_role() {
|
||||||
local role="server_plus_desktop"
|
local role="server_plus_desktop"
|
||||||
|
|
||||||
# 1. Try the Hub config JSON
|
|
||||||
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
||||||
local r
|
local r
|
||||||
r=$(python3 -c \
|
r=$(python3 -c \
|
||||||
@@ -149,7 +177,6 @@ detect_role() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Fall back to parsing role-state.nix
|
|
||||||
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
||||||
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||||
role="desktop"
|
role="desktop"
|
||||||
@@ -161,6 +188,119 @@ detect_role() {
|
|||||||
echo "$role"
|
echo "$role"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_target_mount() {
|
||||||
|
local target="$1"
|
||||||
|
[[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT."
|
||||||
|
[[ -d "$target" ]] || fail "Target path '$target' does not exist."
|
||||||
|
mountpoint -q "$target" || fail "Target path '$target' is not a mount point."
|
||||||
|
|
||||||
|
local fstype=""
|
||||||
|
fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true)
|
||||||
|
[[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'."
|
||||||
|
|
||||||
|
if [[ "$fstype" != "ext4" ]]; then
|
||||||
|
fail "Target '$target' must be formatted as ext4 (detected filesystem: $fstype). Manual Backup requires an ext4-formatted external drive for Linux metadata preservation. exFAT, FAT32, and NTFS are not supported."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local write_test
|
||||||
|
write_test="$target/.sovran-write-test-$$"
|
||||||
|
if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then
|
||||||
|
fail "Target '$target' is not writable."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Verified backup target filesystem: $fstype"
|
||||||
|
}
|
||||||
|
|
||||||
|
estimate_path_bytes() {
|
||||||
|
local path="$1"
|
||||||
|
shift || true
|
||||||
|
[[ -e "$path" ]] || {
|
||||||
|
echo 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
local size
|
||||||
|
size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true)
|
||||||
|
[[ -n "$size" ]] || size=0
|
||||||
|
echo "$size"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Sync one source tree to its backup destination ───────────────
|
||||||
|
# Usage: sync_tree <label> <allow_vanished> <source> <destination> [rsync options...]
|
||||||
|
#
|
||||||
|
# allow_vanished: "yes" means rsync exit 24 (vanished files) is nonfatal.
|
||||||
|
# Used for /home only — files may disappear while the desktop is active.
|
||||||
|
# All other nonzero exit codes are always fatal.
|
||||||
|
#
|
||||||
|
# Before every rsync call this helper:
|
||||||
|
# 1. Re-verifies $TARGET is still a mount point (fails if drive disconnected).
|
||||||
|
# 2. Verifies the destination path remains beneath $BACKUP_DIR and $BACKUP_DIR
|
||||||
|
# remains beneath $TARGET (safe-path check).
|
||||||
|
# 3. Creates the full destination directory hierarchy with mkdir -p so that
|
||||||
|
# rsync never fails trying to create a directory whose parent is absent.
|
||||||
|
sync_tree() {
|
||||||
|
local label="$1"
|
||||||
|
local allow_vanished="$2"
|
||||||
|
local source="$3"
|
||||||
|
local destination="$4"
|
||||||
|
shift 4
|
||||||
|
# Remaining "$@" are rsync options (--exclude, etc.)
|
||||||
|
|
||||||
|
# ── Re-verify the external drive is still mounted ────────────────
|
||||||
|
mountpoint -q "$TARGET" 2>/dev/null || \
|
||||||
|
fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write."
|
||||||
|
|
||||||
|
# ── Verify path safety ────────────────────────────────────────────
|
||||||
|
# BACKUP_DIR must remain beneath TARGET.
|
||||||
|
case "$BACKUP_DIR" in
|
||||||
|
"$TARGET"/*) ;;
|
||||||
|
*) fail "Stage $label: BACKUP_DIR '$BACKUP_DIR' is outside TARGET '$TARGET'." ;;
|
||||||
|
esac
|
||||||
|
# Destination must remain beneath BACKUP_DIR.
|
||||||
|
case "$destination" in
|
||||||
|
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
|
||||||
|
*) fail "Stage $label: destination '$destination' is outside BACKUP_DIR '$BACKUP_DIR'. Refusing to write." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── Create complete destination directory hierarchy ───────────────
|
||||||
|
# This is the fix for the production failure:
|
||||||
|
# rsync: [Receiver] mkdir ".../current/etc/nixos" failed: No such file or directory
|
||||||
|
# mkdir -p creates all intermediate parents (e.g. current/etc/) before rsync runs.
|
||||||
|
mkdir -p -- "$destination" || \
|
||||||
|
fail "Stage $label: failed to create destination directory '$destination' (source: '$source')."
|
||||||
|
|
||||||
|
local rsync_err_tmp
|
||||||
|
rsync_err_tmp="$(mktemp /tmp/sovran-rsync-err.XXXXXX)"
|
||||||
|
|
||||||
|
local rc=0
|
||||||
|
rsync \
|
||||||
|
--archive \
|
||||||
|
--acls \
|
||||||
|
--xattrs \
|
||||||
|
--hard-links \
|
||||||
|
--numeric-ids \
|
||||||
|
--one-file-system \
|
||||||
|
--partial \
|
||||||
|
"$@" "$source" "$destination" 2>"$rsync_err_tmp" || rc=$?
|
||||||
|
|
||||||
|
if [[ -s "$rsync_err_tmp" ]]; then
|
||||||
|
while IFS= read -r rline; do
|
||||||
|
log "rsync: $rline"
|
||||||
|
done < "$rsync_err_tmp"
|
||||||
|
fi
|
||||||
|
rm -f "$rsync_err_tmp"
|
||||||
|
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
||||||
|
log "NOTE: $label — some files vanished during sync (normal on an active desktop). Your important data is backed up."
|
||||||
|
RSYNC_WARNINGS+=("$label: some files vanished during sync (rsync exit 24 — normal on active desktop)")
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
fail "rsync failed for $label (exit code $rc). See the rsync errors above."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ── Initialise log file ──────────────────────────────────────────
|
# ── Initialise log file ──────────────────────────────────────────
|
||||||
|
|
||||||
: > "$BACKUP_LOG"
|
: > "$BACKUP_LOG"
|
||||||
@@ -169,14 +309,38 @@ set_status "RUNNING"
|
|||||||
log "=== Sovran_SystemsOS External Hub Backup ==="
|
log "=== Sovran_SystemsOS External Hub Backup ==="
|
||||||
log "Starting backup process…"
|
log "Starting backup process…"
|
||||||
|
|
||||||
|
# ── Acquire exclusive run lock ────────────────────────────────────
|
||||||
|
# Prevents two simultaneous backup runs (e.g. from double-click or
|
||||||
|
# stale RUNNING status after a Hub restart).
|
||||||
|
|
||||||
|
LOCK_FILE="/var/lock/sovran-hub-backup.lock"
|
||||||
|
# Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x).
|
||||||
|
exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \
|
||||||
|
fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable."
|
||||||
|
flock --nonblock "$LOCK_FD" 2>/dev/null || \
|
||||||
|
fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS."
|
||||||
|
|
||||||
|
require_cmd rsync
|
||||||
|
require_cmd findmnt
|
||||||
|
require_cmd lsblk
|
||||||
|
require_cmd mountpoint
|
||||||
|
require_cmd df
|
||||||
|
require_cmd du
|
||||||
|
require_cmd awk
|
||||||
|
require_cmd find
|
||||||
|
require_cmd hostname
|
||||||
|
require_cmd date
|
||||||
|
require_cmd python3
|
||||||
|
require_cmd flock
|
||||||
|
|
||||||
# ── Detect system role ───────────────────────────────────────────
|
# ── Detect system role ───────────────────────────────────────────
|
||||||
|
|
||||||
ROLE="$(detect_role)"
|
ROLE="$(detect_role)"
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
desktop) ROLE_LABEL="Desktop Only" ;;
|
desktop) ROLE_LABEL="Desktop Only" ;;
|
||||||
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
||||||
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
||||||
*) ROLE_LABEL="$ROLE" ;;
|
*) ROLE_LABEL="$ROLE" ;;
|
||||||
esac
|
esac
|
||||||
log "Detected role: $ROLE_LABEL"
|
log "Detected role: $ROLE_LABEL"
|
||||||
|
|
||||||
@@ -184,7 +348,6 @@ log "Detected role: $ROLE_LABEL"
|
|||||||
|
|
||||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||||
TARGET="$BACKUP_TARGET"
|
TARGET="$BACKUP_TARGET"
|
||||||
# Safety: never allow internal drives even if explicitly passed
|
|
||||||
if is_internal "$TARGET"; then
|
if is_internal "$TARGET"; then
|
||||||
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
||||||
fi
|
fi
|
||||||
@@ -193,39 +356,74 @@ else
|
|||||||
log "Auto-detecting external USB drives…"
|
log "Auto-detecting external USB drives…"
|
||||||
TARGET="$(find_external_drive)"
|
TARGET="$(find_external_drive)"
|
||||||
if [[ -z "$TARGET" ]]; then
|
if [[ -z "$TARGET" ]]; then
|
||||||
fail "No external USB drive detected. " \
|
fail "No external USB drive detected. Please plug in an ext4-formatted USB drive and try again."
|
||||||
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
|
|
||||||
fi
|
fi
|
||||||
log "Detected external drive: $TARGET"
|
log "Detected external drive: $TARGET"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Verify mount point ───────────────────────────────────────────
|
validate_target_mount "$TARGET"
|
||||||
|
|
||||||
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
|
# ── Set up stable backup destination ────────────────────────────
|
||||||
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
|
# Subsequent runs update the same mirror, transferring only new or changed files.
|
||||||
|
|
||||||
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
|
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
|
||||||
|
mkdir -p -- "$BACKUP_DIR"
|
||||||
|
|
||||||
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
|
# Remove any stale BACKUP_COMPLETE left by a previous successful run.
|
||||||
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
|
# The new run will re-earn it only after all stages succeed.
|
||||||
log "Free space on drive: ${FREE_GB} GB"
|
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||||
(( FREE_GB >= MIN_FREE_GB )) || \
|
|
||||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
|
|
||||||
|
|
||||||
# ── Create timestamped backup directory ─────────────────────────
|
# Write an INCOMPLETE marker immediately; replaced by BACKUP_COMPLETE only
|
||||||
|
# after all rsync stages and manifest write succeed. Failed or interrupted
|
||||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
# runs keep this marker so they are clearly identifiable.
|
||||||
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
|
touch "$BACKUP_DIR/INCOMPLETE"
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
log "Backup destination: $BACKUP_DIR"
|
log "Backup destination: $BACKUP_DIR"
|
||||||
|
|
||||||
|
# ── Estimate required free space ─────────────────────────────────
|
||||||
|
# PostgreSQL/MariaDB raw directories and Bitcoin/Electrs data are excluded
|
||||||
|
# from the estimate to avoid inflating the required size.
|
||||||
|
|
||||||
|
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
||||||
|
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
||||||
|
SECRETS_BYTES=0
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
|
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
||||||
|
fi
|
||||||
|
|
||||||
|
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
||||||
|
--exclude='postgresql' \
|
||||||
|
--exclude='mysql' \
|
||||||
|
--exclude='mariadb' \
|
||||||
|
--exclude='bitcoind' \
|
||||||
|
--exclude='electrs' \
|
||||||
|
--exclude='*/log' \
|
||||||
|
--exclude='*/logs' \
|
||||||
|
--exclude='*/cache' \
|
||||||
|
--exclude='*/tmp')
|
||||||
|
|
||||||
|
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
||||||
|
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
||||||
|
# Later incremental runs need far less space, but a conservative first-run
|
||||||
|
# check protects against running out of space mid-backup.
|
||||||
|
REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES ))
|
||||||
|
|
||||||
|
FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ')
|
||||||
|
FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 ))
|
||||||
|
REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 ))
|
||||||
|
|
||||||
|
log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB"
|
||||||
|
log "Required free space (with safety margin): ${REQUIRED_GB} GB"
|
||||||
|
log "Free space on drive: ${FREE_GB} GB"
|
||||||
|
|
||||||
|
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
||||||
|
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
||||||
|
|
||||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
||||||
if [[ -d /etc/nixos ]]; then
|
if [[ -d /etc/nixos ]]; then
|
||||||
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
||||||
fail "Stage 1 failed while copying /etc/nixos"
|
|
||||||
log "Stage 1 complete."
|
log "Stage 1 complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /etc/nixos not found — skipping."
|
log "WARNING: /etc/nixos not found — skipping."
|
||||||
@@ -234,93 +432,158 @@ fi
|
|||||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 2/4: Secrets ───────────────────────────────────────"
|
log "── Stage 2/4: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||||
mkdir -p "$BACKUP_DIR/secrets"
|
|
||||||
|
|
||||||
if [[ "$ROLE" == "desktop" ]]; then
|
if [[ "$ROLE" == "desktop" ]]; then
|
||||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
||||||
# /var/lib/domains is still backed up if present (hub state)
|
elif [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||||
for SRC in /var/lib/domains; do
|
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
||||||
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
|
else
|
||||||
for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do
|
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||||
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
|
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)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Stage 2 complete."
|
log "Stage 2 complete."
|
||||||
|
|
||||||
# ── Stage 3/4: Home directory ────────────────────────────────────
|
# ── Stage 3/4: Home directory ───────────────────────────────────
|
||||||
|
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
||||||
|
# because the desktop may be active and files can disappear between the
|
||||||
|
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
||||||
if [[ -d /home ]]; then
|
if [[ -d /home ]]; then
|
||||||
rsync -a --info=progress2 \
|
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
||||||
--exclude='.cache/' \
|
--exclude='.cache/' \
|
||||||
--exclude='.local/share/Trash/' \
|
--exclude='.local/share/Trash/' \
|
||||||
--exclude='*/Trash/' \
|
--exclude='Trash/' \
|
||||||
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
--exclude='.mozilla/firefox/*/cache2/' \
|
||||||
fail "Stage 3 failed while copying /home"
|
--exclude='.mozilla/firefox/*/startupCache/' \
|
||||||
|
--exclude='.mozilla/firefox/*/thumbnails/' \
|
||||||
|
--exclude='.config/google-chrome/*/Cache/' \
|
||||||
|
--exclude='.config/google-chrome/*/Code Cache/' \
|
||||||
|
--exclude='.config/chromium/*/Cache/' \
|
||||||
|
--exclude='.config/chromium/*/Code Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||||
|
--exclude='.local/share/baloo/' \
|
||||||
|
--exclude='.thumbnails/' \
|
||||||
|
--exclude='.xsession-errors' \
|
||||||
|
--exclude='.xsession-errors.old'
|
||||||
log "Stage 3 complete."
|
log "Stage 3 complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /home not found — skipping."
|
log "WARNING: /home not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 4/4: Wallet and node data ─────────────────────────────
|
# ── Stage 4/4: System data ──────────────────────────────────────
|
||||||
|
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
||||||
|
# databases must be backed up separately with native database tools.
|
||||||
|
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────"
|
log "── Stage 4/4: System data (/var/lib) ───────────────────────"
|
||||||
if [[ "$ROLE" == "desktop" ]]; then
|
if [[ -d /var/lib ]]; then
|
||||||
log "Skipping Stage 4 (LND wallet data) — not applicable for Desktop Only role."
|
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
||||||
elif [[ -d /var/lib/lnd ]]; then
|
--exclude='postgresql/' \
|
||||||
rsync -a --info=progress2 \
|
--exclude='mysql/' \
|
||||||
--exclude='logs/' \
|
--exclude='mariadb/' \
|
||||||
/var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
--exclude='bitcoind/' \
|
||||||
fail "Stage 4 failed while copying /var/lib/lnd"
|
--exclude='electrs/' \
|
||||||
|
--exclude='*/log/' \
|
||||||
|
--exclude='*/logs/' \
|
||||||
|
--exclude='*/cache/' \
|
||||||
|
--exclude='*/tmp/'
|
||||||
log "Stage 4 complete."
|
log "Stage 4 complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /var/lib/lnd not found — skipping."
|
log "WARNING: /var/lib not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Generate manifest ────────────────────────────────────────────
|
# ── Generate manifest ────────────────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "Generating BACKUP_MANIFEST.txt …"
|
log "Generating BACKUP_MANIFEST.txt …"
|
||||||
|
MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "Sovran_SystemsOS Backup Manifest"
|
echo "Sovran_SystemsOS Backup Manifest"
|
||||||
echo "Generated: $(date)"
|
echo "Updated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||||
echo "Hostname: $(hostname)"
|
echo "Hostname: $(hostname)"
|
||||||
echo "Role: $ROLE_LABEL"
|
echo "Role: $ROLE_LABEL"
|
||||||
echo "Target: $TARGET"
|
echo "Target: $TARGET"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Contents:"
|
echo "Backup type: Live rsync mirror (directly browsable files)"
|
||||||
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
|
echo "Location: ${BACKUP_DIR}"
|
||||||
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
echo ""
|
||||||
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
|
echo "Source paths mirrored:"
|
||||||
|
echo "- /etc/nixos → current/etc/nixos/"
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
|
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
||||||
|
fi
|
||||||
|
echo "- /home → current/home/"
|
||||||
|
echo "- /var/lib → current/var/lib/"
|
||||||
|
echo ""
|
||||||
|
echo "Exclusions:"
|
||||||
|
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
||||||
|
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
||||||
|
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
||||||
|
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
||||||
|
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
||||||
|
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
||||||
|
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
||||||
|
echo ""
|
||||||
|
echo "Important limitations:"
|
||||||
|
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
||||||
|
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
||||||
|
echo " applications, their data must be backed up separately using native tools."
|
||||||
|
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
||||||
|
echo " reconstructable or stored on the internal second drive."
|
||||||
|
echo "- This is a live file-level mirror, not a transactional database backup."
|
||||||
|
echo " Files being written during the backup may be in an inconsistent state."
|
||||||
|
echo ""
|
||||||
|
echo "Restore guidance:"
|
||||||
|
echo "- Files are directly browsable on the backup drive under: ${BACKUP_DIR}"
|
||||||
|
echo "- To restore a directory:"
|
||||||
|
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
||||||
|
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
||||||
|
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
||||||
|
echo "- To copy individual files:"
|
||||||
|
echo " sudo cp -a current/home/username/ /home/username/"
|
||||||
|
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
||||||
|
echo " hardware-configuration.nix for the new hardware before rebuilding."
|
||||||
|
echo ""
|
||||||
|
echo "Nonfatal warnings:"
|
||||||
|
if [[ "${#RSYNC_WARNINGS[@]}" -eq 0 ]]; then
|
||||||
|
echo "- none"
|
||||||
|
else
|
||||||
|
for warning in "${RSYNC_WARNINGS[@]}"; do
|
||||||
|
echo "- $warning"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
||||||
|
echo "from manual external backup because they already live on the internal second drive"
|
||||||
|
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
||||||
|
} > "$MANIFEST_FILE"
|
||||||
|
|
||||||
|
log "Manifest written to $MANIFEST_FILE"
|
||||||
|
|
||||||
# ── Done ─────────────────────────────────────────────────────────
|
# ── Done ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
|
if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
|
||||||
|
log "Backup completed with nonfatal warnings:"
|
||||||
|
for warning in "${RSYNC_WARNINGS[@]}"; do
|
||||||
|
log " WARNING: $warning"
|
||||||
|
done
|
||||||
|
log "Your important data is backed up. The warnings above indicate files that"
|
||||||
|
log "vanished during backup, which is normal on an active desktop."
|
||||||
|
log ""
|
||||||
|
fi
|
||||||
log "All Finished! Your data is now backed up to a third location."
|
log "All Finished! Your data is now backed up to a third location."
|
||||||
|
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
||||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||||
|
|
||||||
|
# Remove incomplete marker and write completion marker only after all work succeeds.
|
||||||
|
# A later successful run will update the same mirror and replace any INCOMPLETE state.
|
||||||
|
rm -f "$BACKUP_DIR/INCOMPLETE"
|
||||||
|
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||||
|
|
||||||
|
BACKUP_COMPLETE=1
|
||||||
set_status "SUCCESS"
|
set_status "SUCCESS"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||||
Dark theme matching the Adwaita dark aesthetic
|
Dark theme — near-black with green accents matching the Sovran Hub icon
|
||||||
v6 — Status-only tiles (no controls) */
|
v8 — Black-forward, green used for accents/borders/highlights only */
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -9,22 +9,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #1e1e2e;
|
--bg-color: #080a09;
|
||||||
--surface-color: #2a2a3c;
|
--surface-color: rgba(14, 16, 15, 0.7);
|
||||||
--card-color: #313244;
|
--card-color: rgba(20, 22, 21, 0.6);
|
||||||
--border-color: #45475a;
|
--border-color: rgba(255, 255, 255, 0.06);
|
||||||
--text-primary: #cdd6f4;
|
--text-primary: #ecf3ef;
|
||||||
--text-secondary: #a6adc8;
|
--text-secondary: #8aaa9a;
|
||||||
--text-dim: #6c7086;
|
--text-dim: #4a6658;
|
||||||
--accent-color: #89b4fa;
|
--accent-color: #5EAD8A;
|
||||||
--green: #2ec27e;
|
--green: #6DBF8B;
|
||||||
--yellow: #e5a50a;
|
--yellow: #e5a50a;
|
||||||
--red: #e01b24;
|
--red: #e01b24;
|
||||||
--grey: #888888;
|
--grey: #5E7A6A;
|
||||||
--radius-card: 18px;
|
--radius-card: 18px;
|
||||||
--radius-btn: 8px;
|
--radius-btn: 8px;
|
||||||
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
|
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
|
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -32,8 +32,10 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background-color: var(--bg-color);
|
background:
|
||||||
|
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||||
|
var(--bg-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -53,8 +55,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(14, 16, 15, 0.75);
|
||||||
border: 1px solid var(--border-color);
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 48px 40px;
|
padding: 48px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -112,7 +116,7 @@ body {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ button:disabled {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update System button: BLUE by default */
|
/* Update System button: uses accent green by default */
|
||||||
.btn-update {
|
.btn-update {
|
||||||
background-color: #89b4fa;
|
background-color: #4A9474;
|
||||||
color: #1e1e2e;
|
color: #E0F2EA;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -43,14 +43,14 @@ button:disabled {
|
|||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update System button: GREEN when updates are available */
|
/* Update System button: brighter green when updates are available */
|
||||||
.btn-update.has-updates {
|
.btn-update.has-updates {
|
||||||
background-color: #2ec27e;
|
background-color: #5EAD8A;
|
||||||
color: #fff;
|
color: #0A1A10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-update.has-updates:hover:not(:disabled) {
|
.btn-update.has-updates:hover:not(:disabled) {
|
||||||
background-color: #27ae6e;
|
background-color: #78C8A2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-badge {
|
.update-badge {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ domain-field-label {
|
|||||||
|
|
||||||
domain-field-input {
|
domain-field-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #12121c;
|
background-color: #0c0f0e;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -75,7 +75,7 @@ domain-field-actions {
|
|||||||
|
|
||||||
.domain-field-input {
|
.domain-field-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #12121c;
|
background-color: #0c0f0e;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -145,7 +145,7 @@ domain-field-actions {
|
|||||||
|
|
||||||
.port-req-table td {
|
.port-req-table td {
|
||||||
padding: 8px 10px;
|
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);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.header-bar {
|
.header-bar {
|
||||||
background-color: var(--surface-color);
|
backdrop-filter: blur(14px);
|
||||||
border-bottom: 1px solid var(--border-color);
|
-webkit-backdrop-filter: blur(14px);
|
||||||
padding: 16px 24px;
|
background-color: rgba(10, 12, 11, 0.82);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 8px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-logo {
|
.header-logo {
|
||||||
height: 140px;
|
height: 80px;
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-bar .title {
|
.header-bar .title {
|
||||||
@@ -33,7 +37,7 @@
|
|||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
@@ -44,8 +48,10 @@
|
|||||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.ip-bar {
|
.ip-bar {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(10, 12, 11, 0.7);
|
||||||
border-bottom: 1px solid var(--border-color);
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
padding: 8px 24px;
|
padding: 8px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -68,4 +74,47 @@
|
|||||||
|
|
||||||
.ip-separator {
|
.ip-separator {
|
||||||
color: var(--border-color);
|
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;
|
flex-shrink: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(12, 14, 13, 0.65);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
padding: 20px 14px;
|
padding: 20px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
.sidebar-support-btn:hover {
|
.sidebar-support-btn:hover {
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background-color: #35354a;
|
background-color: #162320;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-support-btn + .sidebar-support-btn {
|
.sidebar-support-btn + .sidebar-support-btn {
|
||||||
@@ -82,23 +84,6 @@
|
|||||||
margin: 16px 0;
|
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 modal ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.upgrade-dialog {
|
.upgrade-dialog {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
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;
|
z-index: 200;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -15,15 +17,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-dialog {
|
.modal-dialog {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(14, 16, 15, 0.8);
|
||||||
border: 1px solid var(--border-color);
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.modal-header {
|
||||||
@@ -71,7 +75,7 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: #12121c;
|
background-color: #0c0f0e;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -89,18 +93,60 @@
|
|||||||
/* Reboot = GREEN */
|
/* Reboot = GREEN */
|
||||||
.modal-footer .btn-reboot,
|
.modal-footer .btn-reboot,
|
||||||
button.btn-reboot {
|
button.btn-reboot {
|
||||||
background-color: #2ec27e;
|
background-color: #6DBF8B;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer .btn-reboot:hover:not(:disabled),
|
.modal-footer .btn-reboot:hover:not(:disabled),
|
||||||
button.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 {
|
.btn-save {
|
||||||
background-color: var(--yellow);
|
background-color: var(--yellow);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save:hover:not(:disabled) {
|
.btn-save:hover:not(:disabled) {
|
||||||
@@ -113,21 +159,23 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-close-modal:hover:not(:disabled) {
|
.btn-close-modal:hover:not(:disabled) {
|
||||||
background-color: #5a5c72;
|
background-color: #1c2a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Credentials info modal ──────────────────────────────────────── */
|
/* ── Credentials info modal ──────────────────────────────────────── */
|
||||||
|
|
||||||
.creds-dialog {
|
.creds-dialog {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(14, 16, 15, 0.8);
|
||||||
border: 1px solid var(--border-color);
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
animation: creds-fade-in 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +195,17 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex: 1;
|
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 {
|
.creds-close-btn {
|
||||||
@@ -203,7 +262,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: #12121c;
|
background-color: #0c0f0e;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -228,7 +287,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.creds-copy-btn:hover {
|
.creds-copy-btn:hover {
|
||||||
background-color: #5a5c72;
|
background-color: #1c2a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.creds-copy-btn.copied {
|
.creds-copy-btn.copied {
|
||||||
@@ -272,7 +331,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
|
|
||||||
.matrix-action-btn {
|
.matrix-action-btn {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #0f0f19;
|
color: #0A1A10;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
@@ -284,7 +343,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.matrix-action-btn:hover {
|
.matrix-action-btn:hover {
|
||||||
background-color: #a8c8ff;
|
background-color: #7CC4A0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-form-group {
|
.matrix-form-group {
|
||||||
@@ -301,7 +360,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
|
|
||||||
.matrix-form-input {
|
.matrix-form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #12121c;
|
background-color: #0c0f0e;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -336,7 +395,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
|
|
||||||
.matrix-form-submit {
|
.matrix-form-submit {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #0f0f19;
|
color: #0A1A10;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
@@ -347,7 +406,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.matrix-form-submit:hover:not(:disabled) {
|
.matrix-form-submit:hover:not(:disabled) {
|
||||||
background-color: #a8c8ff;
|
background-color: #7CC4A0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-form-submit:disabled {
|
.matrix-form-submit:disabled {
|
||||||
@@ -367,7 +426,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.matrix-form-back:hover {
|
.matrix-form-back:hover {
|
||||||
background-color: #5a5c72;
|
background-color: #1c2a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-form-result {
|
.matrix-form-result {
|
||||||
@@ -380,7 +439,7 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.matrix-form-result.success {
|
.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);
|
border: 1px solid var(--green);
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: 100vh;
|
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;
|
padding: 24px 16px 48px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -68,13 +70,13 @@
|
|||||||
.onboarding-step-dot.active {
|
.onboarding-step-dot.active {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-step-dot.completed {
|
.onboarding-step-dot.completed {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-step-connector {
|
.onboarding-step-connector {
|
||||||
@@ -137,8 +139,10 @@
|
|||||||
/* Cards */
|
/* Cards */
|
||||||
|
|
||||||
.onboarding-card {
|
.onboarding-card {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(14, 16, 15, 0.65);
|
||||||
border: 1px solid var(--border-color);
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -146,17 +150,6 @@
|
|||||||
gap: 16px;
|
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 */
|
/* Body text */
|
||||||
|
|
||||||
.onboarding-body-text {
|
.onboarding-body-text {
|
||||||
@@ -191,10 +184,10 @@
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
background-color: rgba(137, 180, 250, 0.12);
|
background-color: rgba(94, 173, 138, 0.10);
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(137, 180, 250, 0.3);
|
border: 1px solid rgba(94, 173, 138, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step header */
|
/* Step header */
|
||||||
@@ -228,7 +221,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 4px;
|
padding-top: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-btn-next {
|
.onboarding-btn-next {
|
||||||
@@ -278,7 +273,7 @@
|
|||||||
background-color: var(--card-color);
|
background-color: var(--card-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 0.88rem;
|
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;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +351,7 @@
|
|||||||
|
|
||||||
.onboarding-port-table td {
|
.onboarding-port-table td {
|
||||||
padding: 8px 8px;
|
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);
|
color: var(--text-secondary);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -414,8 +409,8 @@
|
|||||||
|
|
||||||
.onboarding-creds-notice {
|
.onboarding-creds-notice {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background-color: rgba(137, 180, 250, 0.08);
|
background-color: rgba(94, 173, 138, 0.08);
|
||||||
border: 1px solid rgba(137, 180, 250, 0.25);
|
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -508,8 +503,8 @@
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: rgba(137, 180, 250, 0.1);
|
background-color: rgba(94, 173, 138, 0.08);
|
||||||
border: 1px solid rgba(137, 180, 250, 0.25);
|
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -517,7 +512,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-cred-reveal-btn:hover {
|
.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) */
|
/* Completion checklist (Step 5) */
|
||||||
@@ -575,13 +570,174 @@
|
|||||||
color: var(--text-secondary);
|
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 ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.reboot-overlay {
|
.reboot-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
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;
|
z-index: 999;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -592,8 +748,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reboot-card {
|
.reboot-card {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(14, 16, 15, 0.8);
|
||||||
border: 1px solid var(--border-color);
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 48px 56px;
|
padding: 48px 56px;
|
||||||
text-align: center;
|
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 {
|
.support-steps code {
|
||||||
background-color: rgba(137, 180, 250, 0.12);
|
background-color: rgba(94, 173, 138, 0.10);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-btn-auditlog:hover:not(:disabled) {
|
.support-btn-auditlog:hover:not(:disabled) {
|
||||||
background-color: #5a5c72;
|
background-color: #1c2a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-fine-print {
|
.support-fine-print {
|
||||||
@@ -219,8 +219,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-wallet-protected {
|
.support-wallet-protected {
|
||||||
background-color: rgba(46, 194, 126, 0.06);
|
background-color: rgba(109, 191, 139, 0.06);
|
||||||
border-color: rgba(46, 194, 126, 0.3);
|
border-color: rgba(109, 191, 139, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-wallet-unlocked {
|
.support-wallet-unlocked {
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--yellow);
|
background-color: var(--yellow);
|
||||||
color: #1e1e2e;
|
color: #0A1A10;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||||
background-color: #27ae6e;
|
background-color: #529E7E;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
.support-audit-log {
|
.support-audit-log {
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: #12121c;
|
background-color: #0c0f0e;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 3px 0;
|
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 {
|
.support-audit-entry:last-child {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
min-height: 130px;
|
min-height: 130px;
|
||||||
background-color: var(--card-color);
|
background-color: var(--card-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
|
|
||||||
.service-tile:hover {
|
.service-tile:hover {
|
||||||
box-shadow: var(--shadow-hover);
|
box-shadow: var(--shadow-hover);
|
||||||
border-color: #6c7086;
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-tile.disabled {
|
.service-tile.disabled {
|
||||||
@@ -93,6 +95,7 @@
|
|||||||
.status-dot.disabled { background-color: var(--grey); }
|
.status-dot.disabled { background-color: var(--grey); }
|
||||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
.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 ──────────────────────────────── */
|
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
|
||||||
|
|
||||||
@@ -152,6 +155,69 @@
|
|||||||
white-space: nowrap;
|
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 ───────────────────────────────── */
|
/* ── Service detail modal sections ───────────────────────────────── */
|
||||||
|
|
||||||
.svc-detail-section {
|
.svc-detail-section {
|
||||||
@@ -235,7 +301,7 @@
|
|||||||
|
|
||||||
.svc-detail-port-table td {
|
.svc-detail-port-table td {
|
||||||
padding: 8px 10px;
|
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);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +360,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.svc-detail-troubleshoot code {
|
.svc-detail-troubleshoot code {
|
||||||
background-color: rgba(137, 180, 250, 0.12);
|
background-color: rgba(94, 173, 138, 0.10);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -310,6 +376,12 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Service detail: Domain configure button ─────────────────────── */
|
||||||
|
|
||||||
|
.svc-detail-domain-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
||||||
|
|
||||||
.svc-detail-addon-row {
|
.svc-detail-addon-row {
|
||||||
@@ -343,3 +415,59 @@
|
|||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
font-weight: 600;
|
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 POLL_INTERVAL_UPDATES = 1800000;
|
||||||
const UPDATE_POLL_INTERVAL = 2000;
|
const UPDATE_POLL_INTERVAL = 2000;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
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 SUPPORT_TIMER_INTERVAL = 1000;
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
|||||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
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
|
// Rebuild modal
|
||||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||||
@@ -38,6 +44,28 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
|
|||||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) 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 ───────────────────────────────────────
|
// ── Upgrade modal functions ───────────────────────────────────────
|
||||||
|
|
||||||
function openUpgradeModal() {
|
function openUpgradeModal() {
|
||||||
@@ -48,18 +76,63 @@ function closeUpgradeModal() {
|
|||||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
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() {
|
async function doUpgradeToServer() {
|
||||||
var confirmBtn = $upgradeConfirmBtn;
|
var confirmBtn = $upgradeConfirmBtn;
|
||||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||||
closeUpgradeModal();
|
closeUpgradeModal();
|
||||||
|
|
||||||
// Reuse the rebuild modal to show progress
|
// Reuse the rebuild modal to show reboot progress
|
||||||
_rebuildFeatureName = "Server + Desktop";
|
_rebuildFeatureName = "Server + Desktop";
|
||||||
_rebuildIsEnabling = true;
|
_rebuildIsEnabling = true;
|
||||||
openRebuildModal();
|
openRebuildModal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/role/upgrade-to-server", { method: "POST" });
|
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) {
|
} catch (err) {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
|
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
|
||||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||||
@@ -70,6 +143,47 @@ async function doUpgradeToServer() {
|
|||||||
|
|
||||||
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", 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 ──────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -84,6 +198,17 @@ async function init() {
|
|||||||
// If we can't reach the endpoint, continue to normal dashboard
|
// 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 {
|
try {
|
||||||
var cfg = await apiFetch("/api/config");
|
var cfg = await apiFetch("/api/config");
|
||||||
_currentRole = cfg.role || "server_plus_desktop";
|
_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>';
|
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 =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
'<p><strong>Before continuing:</strong></p>' +
|
introHtml +
|
||||||
'<ol>' +
|
'<details style="margin-top:10px;">' +
|
||||||
'<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>' +
|
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
||||||
'<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>' +
|
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||||
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
||||||
'<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>Set record type to <strong>Dynamic</strong>.</li>' +
|
||||||
'<li>Njal.la will give you a curl command like:<br>' +
|
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
||||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
'(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>)<br>' +
|
||||||
'<li>Enter the subdomain and paste that curl command below</li>' +
|
'⚠ 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>' +
|
'</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>' +
|
||||||
'<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>' +
|
'<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 +
|
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>';
|
'<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-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() {
|
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||||
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||||
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||||
@@ -124,7 +227,7 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
onSaved(npub);
|
onSaved(npub);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.textContent = "Save & Enable";
|
saveBtn.textContent = "Save & Update";
|
||||||
alert("Failed to save domain. Please try again.");
|
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>'
|
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Show loading state while fetching port status
|
function renderPortRequirements(internalIp) {
|
||||||
$portReqBody.innerHTML =
|
var rows = ports.map(function(p) {
|
||||||
'<p class="port-req-intro">Checking port status for <strong>' + escHtml(featureName) + '</strong>…</p>' +
|
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||||
'<p class="port-req-hint">Detecting which ports are open on this machine…</p>';
|
'<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");
|
$portReqModal.classList.add("open");
|
||||||
|
renderPortRequirements(null);
|
||||||
|
|
||||||
// Fetch live port status from local system commands (no external calls)
|
fetch("/api/network")
|
||||||
fetch("/api/ports/status", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ports: ports }),
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
if (!$portReqModal.classList.contains("open")) return;
|
||||||
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
|
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
|
||||||
? data.internal_ip : null;
|
? data.internal_ip : null;
|
||||||
var portStatuses = {};
|
renderPortRequirements(internalIp);
|
||||||
(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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function(err) {
|
||||||
// Fallback: show static table without status column if fetch fails
|
console.warn("Failed to fetch network info for port requirements modal:", err);
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,25 +397,52 @@ function handleFeatureToggle(feat, newEnabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function proceedAfterConflictCheck() {
|
function proceedAfterConflictCheck() {
|
||||||
// Show port requirements notification if the feature has extra port needs
|
|
||||||
var ports = feat.port_requirements || [];
|
var ports = feat.port_requirements || [];
|
||||||
if (ports.length > 0) {
|
if (ports.length === 0) {
|
||||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
|
||||||
} else {
|
|
||||||
proceedAfterPortCheck();
|
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) {
|
if (feat.id === "bitcoin-core") {
|
||||||
var confirmMsg;
|
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?";
|
||||||
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?";
|
|
||||||
}
|
|
||||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||||
|
} else if (conflictNames.length > 0) {
|
||||||
|
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||||
} else {
|
} else {
|
||||||
proceedAfterConflictCheck();
|
proceedAfterConflictCheck();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function statusClass(health) {
|
|||||||
if (health === "disabled") return "disabled";
|
if (health === "disabled") return "disabled";
|
||||||
if (health === "syncing") return "syncing";
|
if (health === "syncing") return "syncing";
|
||||||
if (STATUS_LOADING_STATES.has(health)) return "loading";
|
if (STATUS_LOADING_STATES.has(health)) return "loading";
|
||||||
|
if (health === "checking_reachability") return "checking-reachability";
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ function statusText(health, enabled) {
|
|||||||
if (health === "syncing") return "Syncing\u2026";
|
if (health === "syncing") return "Syncing\u2026";
|
||||||
if (!health || health === "unknown") return "Unknown";
|
if (!health || health === "unknown") return "Unknown";
|
||||||
if (STATUS_LOADING_STATES.has(health)) return health;
|
if (STATUS_LOADING_STATES.has(health)) return health;
|
||||||
|
if (health === "checking_reachability") return "Checking\u2026";
|
||||||
return health;
|
return health;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,3 +60,17 @@ async function apiFetch(path, options) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
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;
|
if (data.running) return;
|
||||||
_rebuildFinished = true;
|
_rebuildFinished = true;
|
||||||
stopRebuildPoll();
|
stopRebuildPoll();
|
||||||
onRebuildDone(data.result === "success");
|
if (data.result === "reboot_required") {
|
||||||
|
onRebuildDone("reboot_required");
|
||||||
|
} else {
|
||||||
|
onRebuildDone(data.result === "success");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRebuildDone(success) {
|
function onRebuildDone(result) {
|
||||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||||
if (success) {
|
if (result === true) {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
||||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||||
setTimeout(function() { window.location.reload(); }, 1200);
|
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 {
|
} else {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
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++) {
|
for (var i = 0; i < credentials.length; i++) {
|
||||||
var cred = credentials[i];
|
var cred = credentials[i];
|
||||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||||
var displayValue = linkify(cred.value);
|
|
||||||
var qrBlock = "";
|
var qrBlock = "";
|
||||||
if (cred.qrcode) {
|
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>';
|
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>';
|
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;
|
return html;
|
||||||
@@ -55,7 +60,20 @@ function _attachCopyHandlers(container) {
|
|||||||
|
|
||||||
async function openServiceDetailModal(unit, name, icon) {
|
async function openServiceDetailModal(unit, name, icon) {
|
||||||
if (!$credsModal) return;
|
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>';
|
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||||
$credsModal.classList.add("open");
|
$credsModal.classList.add("open");
|
||||||
|
|
||||||
@@ -89,156 +107,137 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// Section C: Ports (only if service has port_requirements)
|
// Section B2: BIP-110 live status (bip110 tile only)
|
||||||
if (data.port_statuses && data.port_statuses.length > 0) {
|
if (icon === 'bip110' && data.bip110) {
|
||||||
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
|
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 = "";
|
var portTableRows = "";
|
||||||
data.port_statuses.forEach(function(p) {
|
data.port_statuses.forEach(function(p) {
|
||||||
var statusIcon, statusClass2;
|
var statusIcon, statusClass2;
|
||||||
if (p.status === "listening") {
|
if (p.status === "listening") {
|
||||||
statusIcon = "✅ Open";
|
statusIcon = "✅ Ready";
|
||||||
statusClass2 = "port-status-listening";
|
statusClass2 = "port-status-listening";
|
||||||
} else if (p.status === "firewall_open") {
|
} else if (p.status === "firewall_open") {
|
||||||
statusIcon = "🟡 Firewall open";
|
statusIcon = "✅ Ready";
|
||||||
statusClass2 = "port-status-open";
|
statusClass2 = "port-status-open";
|
||||||
} else if (p.status === "closed") {
|
} else if (p.status === "closed") {
|
||||||
statusIcon = "🔴 Closed";
|
statusIcon = "❌ Not ready";
|
||||||
statusClass2 = "port-status-closed";
|
statusClass2 = "port-status-closed";
|
||||||
} else {
|
} else {
|
||||||
statusIcon = "— Unknown";
|
statusIcon = "— Could not check";
|
||||||
statusClass2 = "port-status-unknown";
|
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>' +
|
portTableRows += '<tr>' +
|
||||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
'<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-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>' +
|
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||||
'</tr>';
|
'</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">' +
|
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">' +
|
'<table class="svc-detail-port-table">' +
|
||||||
'<thead><tr>' +
|
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
||||||
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
|
|
||||||
'</tr></thead>' +
|
|
||||||
'<tbody>' + portTableRows + '</tbody>' +
|
'<tbody>' + portTableRows + '</tbody>' +
|
||||||
'</table>' +
|
'</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>';
|
'</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-add-user-btn">➕ Add New User</button>' +
|
||||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||||
'</div>' : "") +
|
'</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>';
|
'</div>';
|
||||||
} else if (!data.enabled && !data.feature) {
|
} else if (!data.enabled && !data.feature) {
|
||||||
html += '<div class="svc-detail-section">' +
|
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";
|
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
|
||||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
// 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"
|
? "\u20BF Bitcoin Node Selection"
|
||||||
: "\uD83D\uDD27 Addon Feature";
|
: "\uD83D\uDD27 Addon Feature";
|
||||||
|
|
||||||
@@ -309,6 +312,15 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>';
|
'</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;
|
$credsBody.innerHTML = html;
|
||||||
_attachCopyHandlers($credsBody);
|
_attachCopyHandlers($credsBody);
|
||||||
|
|
||||||
@@ -319,6 +331,11 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(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) {
|
if (data.feature) {
|
||||||
var addonBtn = document.getElementById("svc-detail-addon-btn");
|
var addonBtn = document.getElementById("svc-detail-addon-btn");
|
||||||
if (addonBtn) {
|
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) {
|
} catch (err) {
|
||||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
|
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 ────────────────────────────────────────
|
// ── Credentials info modal ────────────────────────────────────────
|
||||||
|
|
||||||
async function openCredsModal(unit, name) {
|
async function openCredsModal(unit, name, icon) {
|
||||||
if (!$credsModal) return;
|
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>';
|
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||||
$credsModal.classList.add("open");
|
$credsModal.classList.add("open");
|
||||||
try {
|
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"); }
|
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 $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
|
||||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
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 $credsModal = document.getElementById("creds-modal");
|
||||||
const $credsTitle = document.getElementById("creds-modal-title");
|
const $credsTitle = document.getElementById("creds-modal-title");
|
||||||
@@ -59,6 +62,8 @@ const $supportModal = document.getElementById("support-modal");
|
|||||||
const $supportBody = document.getElementById("support-body");
|
const $supportBody = document.getElementById("support-body");
|
||||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||||
|
|
||||||
|
const $logoutBtn = document.getElementById("btn-logout");
|
||||||
|
|
||||||
// Feature Manager — rebuild modal
|
// Feature Manager — rebuild modal
|
||||||
const $rebuildModal = document.getElementById("rebuild-modal");
|
const $rebuildModal = document.getElementById("rebuild-modal");
|
||||||
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
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 $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-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
|
// System status banner
|
||||||
// (removed — health is now shown per-tile via the composite health field)
|
// (removed — health is now shown per-tile via the composite health field)
|
||||||
@@ -491,8 +491,9 @@ function renderBackupReady(drives) {
|
|||||||
'<div class="support-steps-title">Requirements</div>',
|
'<div class="support-steps-title">Requirements</div>',
|
||||||
'<ol class="support-backup-steps">',
|
'<ol class="support-backup-steps">',
|
||||||
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
||||||
'<li>At least 500 GB of free space on the drive</li>',
|
'<li>Enough free space for your data (the backup checks this before starting)</li>',
|
||||||
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
'<li>Drive must be formatted as <strong>ext4</strong> (a Linux filesystem). Drives with exFAT, FAT32, or NTFS are not supported. To format a drive as ext4, use a Linux tool such as GParted or <code>mkfs.ext4</code> — note that formatting erases all data on the drive.</li>',
|
||||||
|
'<li>The drive is intended for Linux/Sovran recovery. It may not be directly readable by Windows or macOS without additional software.</li>',
|
||||||
'</ol>',
|
'</ol>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
@@ -500,19 +501,26 @@ function renderBackupReady(drives) {
|
|||||||
'<div class="support-steps-title">What gets backed up</div>',
|
'<div class="support-steps-title">What gets backed up</div>',
|
||||||
'<ol class="support-backup-steps">',
|
'<ol class="support-backup-steps">',
|
||||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
'<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>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>) — excluding databases and blockchain data (see note below)</li>',
|
||||||
'<li>Home directory (<code>/home</code>)</li>',
|
'<li>Home directory (<code>/home</code>)</li>',
|
||||||
'</ol>',
|
'</ol>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
'<div class="support-wallet-box support-wallet-warning">',
|
'<div class="support-wallet-box support-wallet-warning">',
|
||||||
|
'<div class="support-wallet-header">',
|
||||||
|
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
|
||||||
|
'<span class="support-wallet-title">Database and Blockchain Data</span>',
|
||||||
|
'</div>',
|
||||||
|
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
|
||||||
|
'</div>',
|
||||||
|
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
'<div class="support-wallet-header">',
|
'<div class="support-wallet-header">',
|
||||||
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
|
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
|
||||||
'<span class="support-wallet-title">Time Estimate</span>',
|
'<span class="support-wallet-title">Time Estimate</span>',
|
||||||
'</div>',
|
'</div>',
|
||||||
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
|
'<p class="support-wallet-desc">The first backup may take a while depending on how much data you have. Later backups are much faster because only changed or new files are copied. Files are stored directly on the drive and can be browsed without any special software.</p>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
driveSelector,
|
driveSelector,
|
||||||
@@ -585,9 +593,10 @@ async function pollBackupStatus() {
|
|||||||
logDiv.scrollTop = logDiv.scrollHeight;
|
logDiv.scrollTop = logDiv.scrollHeight;
|
||||||
}
|
}
|
||||||
_backupLogOffset = data.offset;
|
_backupLogOffset = data.offset;
|
||||||
if (!data.running) {
|
const result = (data.result || "").toLowerCase();
|
||||||
|
if (result === "success" || result === "failed") {
|
||||||
stopBackupPoll();
|
stopBackupPoll();
|
||||||
renderBackupDone(data.result === "success");
|
renderBackupDone(result === "success");
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -619,7 +628,7 @@ function renderBackupDone(success) {
|
|||||||
'<div class="support-section">',
|
'<div class="support-section">',
|
||||||
'<div class="support-icon-big">\u26a0\ufe0f</div>',
|
'<div class="support-icon-big">\u26a0\ufe0f</div>',
|
||||||
'<h3 class="support-heading">Backup Failed</h3>',
|
'<h3 class="support-heading">Backup Failed</h3>',
|
||||||
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.</p>',
|
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as ext4. Then try again.</p>',
|
||||||
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
|
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
|
||||||
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||||
var _btcSyncPrev = {};
|
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 ─────────────────────────────────────────
|
// ── Render: initial build ─────────────────────────────────────────
|
||||||
|
|
||||||
function buildTiles(services, categoryLabels) {
|
function buildTiles(services, categoryLabels) {
|
||||||
@@ -89,10 +104,22 @@ function renderSidebarSupport(supportServices) {
|
|||||||
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
||||||
$sidebarSupport.appendChild(backupBtn);
|
$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)
|
// ── Upgrade button (Node role only)
|
||||||
if (_currentRole === "node") {
|
if (_currentRole === "node") {
|
||||||
var upgradeBtn = document.createElement("button");
|
var upgradeBtn = document.createElement("button");
|
||||||
upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn";
|
upgradeBtn.className = "sidebar-support-btn";
|
||||||
upgradeBtn.innerHTML =
|
upgradeBtn.innerHTML =
|
||||||
'<span class="sidebar-support-icon">🚀</span>' +
|
'<span class="sidebar-support-icon">🚀</span>' +
|
||||||
'<span class="sidebar-support-text">' +
|
'<span class="sidebar-support-text">' +
|
||||||
@@ -118,8 +145,6 @@ function buildTile(svc) {
|
|||||||
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
||||||
tile.dataset.unit = svc.unit;
|
tile.dataset.unit = svc.unit;
|
||||||
tile.dataset.tileId = tileId(svc);
|
tile.dataset.tileId = tileId(svc);
|
||||||
if (dis) tile.title = svc.name + " is not enabled in custom.nix";
|
|
||||||
|
|
||||||
if (isSupport) {
|
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.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";
|
tile.style.cursor = "pointer";
|
||||||
@@ -127,7 +152,7 @@ function buildTile(svc) {
|
|||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (svc.sync_ibd) {
|
if (svc.sync_ibd && svc.enabled) {
|
||||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||||
var id = tileId(svc);
|
var id = tileId(svc);
|
||||||
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
||||||
@@ -155,7 +180,8 @@ function buildTile(svc) {
|
|||||||
|
|
||||||
var ver = svc.version || svc.bitcoin_version || '';
|
var ver = svc.version || svc.bitcoin_version || '';
|
||||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
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.style.cursor = "pointer";
|
||||||
tile.addEventListener("click", function() {
|
tile.addEventListener("click", function() {
|
||||||
@@ -193,7 +219,7 @@ function updateTiles(services) {
|
|||||||
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
||||||
if (!tile) continue;
|
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 was previously normal, rebuild it with the sync layout
|
||||||
if (!tile.querySelector(".tile-sync-container")) {
|
if (!tile.querySelector(".tile-sync-container")) {
|
||||||
var newTile = buildTile(svc);
|
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 ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function openUpdateModal() {
|
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;
|
if (!$modal) return;
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
@@ -37,6 +62,15 @@ function startUpdate() {
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function(data) {
|
.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 (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||||
startUpdatePoll();
|
startUpdatePoll();
|
||||||
@@ -60,25 +94,68 @@ async function pollUpdateStatus() {
|
|||||||
if (_updateFinished) return;
|
if (_updateFinished) return;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
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);
|
if (data.log) appendLog(data.log);
|
||||||
_updateLogOffset = data.offset;
|
_updateLogOffset = data.offset;
|
||||||
if (data.running) return;
|
if (data.running) return;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
if (data.result === "success") onUpdateDone(true);
|
if (data.result === "reboot_required") {
|
||||||
else onUpdateDone(false);
|
onUpdateDone("reboot_required");
|
||||||
|
} else if (data.result === "success") {
|
||||||
|
onUpdateDone(true);
|
||||||
|
} else {
|
||||||
|
onUpdateDone(false);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
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 ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
if (success) {
|
if (result === true) {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
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 {
|
} else {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||||
@@ -100,21 +177,78 @@ function saveErrorReport() {
|
|||||||
|
|
||||||
// ── Reboot ────────────────────────────────────────────────────────
|
// ── Reboot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _rebootStartTime = 0;
|
||||||
|
var _serverWentDown = false;
|
||||||
|
var _rebootFailed = false;
|
||||||
|
|
||||||
|
function _setRebootStatus(msg) {
|
||||||
|
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
function doReboot() {
|
function doReboot() {
|
||||||
if ($modal) $modal.classList.remove("open");
|
if ($modal) $modal.classList.remove("open");
|
||||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
stopRebuildPoll();
|
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");
|
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||||
fetch("/api/reboot", { method: "POST" }).catch(function() {});
|
_rebootStartTime = Date.now();
|
||||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
_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() {
|
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) {
|
.then(function(res) {
|
||||||
if (res.ok) window.location.reload();
|
clearTimeout(timeoutId);
|
||||||
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
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,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 23.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
version="1.1"
|
version="1.1"
|
||||||
id="Layer_1"
|
id="Layer_1"
|
||||||
@@ -9,6 +11,9 @@
|
|||||||
sodipodi:docname="sovran_systems_2.svg"
|
sodipodi:docname="sovran_systems_2.svg"
|
||||||
width="218.44057"
|
width="218.44057"
|
||||||
height="109.75845"
|
height="109.75845"
|
||||||
|
inkscape:export-filename="sovran_systems_2.svg"
|
||||||
|
inkscape:export-xdpi="169.6163"
|
||||||
|
inkscape:export-ydpi="169.6163"
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -32,59 +37,59 @@
|
|||||||
<g
|
<g
|
||||||
id="g30"
|
id="g30"
|
||||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
||||||
style="fill:#ffffff;fill-opacity:1">
|
style="fill:#dedede;fill-opacity:1">
|
||||||
<path
|
<path
|
||||||
d="m 354.93,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.69,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
d="m 354.93,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.69,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||||
id="path4"
|
id="path4"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 412.05,528.85 c 0,1.81 -0.27,3.46 -0.8,4.94 -0.53,1.48 -1.48,2.74 -2.84,3.78 -1.36,1.04 -3.23,1.86 -5.62,2.45 -2.39,0.59 -5.41,0.89 -9.08,0.89 -3.67,0 -6.7,-0.29 -9.11,-0.89 -2.4,-0.59 -4.29,-1.41 -5.65,-2.45 -1.36,-1.04 -2.31,-2.31 -2.84,-3.78 -0.53,-1.48 -0.8,-3.12 -0.8,-4.94 v -20.17 c 0,-1.81 0.27,-3.46 0.8,-4.94 0.53,-1.48 1.48,-2.75 2.84,-3.81 1.36,-1.06 3.24,-1.89 5.65,-2.48 2.4,-0.59 5.44,-0.89 9.11,-0.89 3.67,0 6.69,0.3 9.08,0.89 2.39,0.59 4.26,1.42 5.62,2.48 1.36,1.06 2.31,2.34 2.84,3.81 0.53,1.48 0.8,3.13 0.8,4.94 z m -23.3,-2.13 c 0,0.79 0.3,1.45 0.89,1.98 0.59,0.53 1.95,0.8 4.08,0.8 2.13,0 3.49,-0.27 4.08,-0.8 0.59,-0.53 0.89,-1.19 0.89,-1.98 v -15.91 c 0,-0.75 -0.3,-1.39 -0.89,-1.92 -0.59,-0.53 -1.95,-0.8 -4.08,-0.8 -2.13,0 -3.49,0.27 -4.08,0.8 -0.59,0.53 -0.89,1.17 -0.89,1.92 z"
|
d="m 412.05,528.85 c 0,1.81 -0.27,3.46 -0.8,4.94 -0.53,1.48 -1.48,2.74 -2.84,3.78 -1.36,1.04 -3.23,1.86 -5.62,2.45 -2.39,0.59 -5.41,0.89 -9.08,0.89 -3.67,0 -6.7,-0.29 -9.11,-0.89 -2.4,-0.59 -4.29,-1.41 -5.65,-2.45 -1.36,-1.04 -2.31,-2.31 -2.84,-3.78 -0.53,-1.48 -0.8,-3.12 -0.8,-4.94 v -20.17 c 0,-1.81 0.27,-3.46 0.8,-4.94 0.53,-1.48 1.48,-2.75 2.84,-3.81 1.36,-1.06 3.24,-1.89 5.65,-2.48 2.4,-0.59 5.44,-0.89 9.11,-0.89 3.67,0 6.69,0.3 9.08,0.89 2.39,0.59 4.26,1.42 5.62,2.48 1.36,1.06 2.31,2.34 2.84,3.81 0.53,1.48 0.8,3.13 0.8,4.94 z m -23.3,-2.13 c 0,0.79 0.3,1.45 0.89,1.98 0.59,0.53 1.95,0.8 4.08,0.8 2.13,0 3.49,-0.27 4.08,-0.8 0.59,-0.53 0.89,-1.19 0.89,-1.98 v -15.91 c 0,-0.75 -0.3,-1.39 -0.89,-1.92 -0.59,-0.53 -1.95,-0.8 -4.08,-0.8 -2.13,0 -3.49,0.27 -4.08,0.8 -0.59,0.53 -0.89,1.17 -0.89,1.92 z"
|
||||||
id="path6"
|
id="path6"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 447.12,540.02 h -15.38 c -0.75,0 -1.37,-0.14 -1.86,-0.41 -0.49,-0.28 -0.88,-0.79 -1.15,-1.54 l -5.8,-14.49 c -0.35,-0.87 -0.65,-1.63 -0.89,-2.28 -0.24,-0.65 -0.43,-1.27 -0.59,-1.86 -0.16,-0.59 -0.27,-1.21 -0.33,-1.86 -0.06,-0.65 -0.09,-1.45 -0.09,-2.4 v -15.61 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.4 c 0.75,0 1.32,0.17 1.71,0.5 0.39,0.34 0.59,0.88 0.59,1.63 v 16.32 c 0,0.39 0.04,0.79 0.12,1.18 0.08,0.39 0.2,0.81 0.35,1.24 l 2.78,8.28 c 0.12,0.39 0.26,0.66 0.41,0.8 0.16,0.14 0.39,0.21 0.71,0.21 h 0.65 c 0.31,0 0.55,-0.07 0.71,-0.21 0.16,-0.14 0.3,-0.4 0.41,-0.8 l 2.78,-8.34 c 0.16,-0.43 0.28,-0.85 0.35,-1.24 0.08,-0.39 0.12,-0.79 0.12,-1.18 v -16.26 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.28 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 15.61 c 0,0.95 -0.03,1.74 -0.09,2.4 -0.06,0.65 -0.17,1.27 -0.33,1.86 -0.16,0.59 -0.35,1.21 -0.59,1.86 -0.24,0.65 -0.53,1.41 -0.89,2.28 l -5.8,14.49 c -0.28,0.75 -0.66,1.26 -1.15,1.54 -0.45,0.28 -1.07,0.41 -1.82,0.41 z"
|
d="m 447.12,540.02 h -15.38 c -0.75,0 -1.37,-0.14 -1.86,-0.41 -0.49,-0.28 -0.88,-0.79 -1.15,-1.54 l -5.8,-14.49 c -0.35,-0.87 -0.65,-1.63 -0.89,-2.28 -0.24,-0.65 -0.43,-1.27 -0.59,-1.86 -0.16,-0.59 -0.27,-1.21 -0.33,-1.86 -0.06,-0.65 -0.09,-1.45 -0.09,-2.4 v -15.61 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.4 c 0.75,0 1.32,0.17 1.71,0.5 0.39,0.34 0.59,0.88 0.59,1.63 v 16.32 c 0,0.39 0.04,0.79 0.12,1.18 0.08,0.39 0.2,0.81 0.35,1.24 l 2.78,8.28 c 0.12,0.39 0.26,0.66 0.41,0.8 0.16,0.14 0.39,0.21 0.71,0.21 h 0.65 c 0.31,0 0.55,-0.07 0.71,-0.21 0.16,-0.14 0.3,-0.4 0.41,-0.8 l 2.78,-8.34 c 0.16,-0.43 0.28,-0.85 0.35,-1.24 0.08,-0.39 0.12,-0.79 0.12,-1.18 v -16.26 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.28 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 15.61 c 0,0.95 -0.03,1.74 -0.09,2.4 -0.06,0.65 -0.17,1.27 -0.33,1.86 -0.16,0.59 -0.35,1.21 -0.59,1.86 -0.24,0.65 -0.53,1.41 -0.89,2.28 l -5.8,14.49 c -0.28,0.75 -0.66,1.26 -1.15,1.54 -0.45,0.28 -1.07,0.41 -1.82,0.41 z"
|
||||||
id="path8"
|
id="path8"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 478.69,540.02 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 19.69 c 4.41,0 7.46,0.92 9.14,2.75 1.68,1.83 2.51,4.21 2.51,7.13 v 2.72 c 0,1.66 -0.25,3.07 -0.74,4.23 -0.49,1.16 -1.35,2 -2.57,2.51 2.13,0.24 3.85,1.1 5.17,2.6 1.32,1.5 1.98,3.49 1.98,5.97 v 12.54 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.17 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -9.05 c 0,-0.87 -0.17,-1.51 -0.5,-1.92 -0.34,-0.41 -0.92,-0.62 -1.74,-0.62 h -8.28 v 11.59 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.62,0.5 z m 2.13,-31.93 v 7.57 h 4.44 c 1.02,0 1.71,-0.27 2.07,-0.8 0.36,-0.53 0.53,-1.19 0.53,-1.98 v -2.01 c 0,-0.79 -0.18,-1.45 -0.53,-1.98 -0.35,-0.53 -1.04,-0.8 -2.07,-0.8 z"
|
d="m 478.69,540.02 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 19.69 c 4.41,0 7.46,0.92 9.14,2.75 1.68,1.83 2.51,4.21 2.51,7.13 v 2.72 c 0,1.66 -0.25,3.07 -0.74,4.23 -0.49,1.16 -1.35,2 -2.57,2.51 2.13,0.24 3.85,1.1 5.17,2.6 1.32,1.5 1.98,3.49 1.98,5.97 v 12.54 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.17 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -9.05 c 0,-0.87 -0.17,-1.51 -0.5,-1.92 -0.34,-0.41 -0.92,-0.62 -1.74,-0.62 h -8.28 v 11.59 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.62,0.5 z m 2.13,-31.93 v 7.57 h 4.44 c 1.02,0 1.71,-0.27 2.07,-0.8 0.36,-0.53 0.53,-1.19 0.53,-1.98 v -2.01 c 0,-0.79 -0.18,-1.45 -0.53,-1.98 -0.35,-0.53 -1.04,-0.8 -2.07,-0.8 z"
|
||||||
id="path10"
|
id="path10"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 525.83,537.89 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 H 515 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -15.61 c 0,-1.18 0.19,-2.54 0.56,-4.08 0.38,-1.54 0.96,-3.33 1.75,-5.38 l 5.15,-13.42 c 0.24,-0.67 0.6,-1.16 1.09,-1.48 0.49,-0.31 1.13,-0.47 1.92,-0.47 h 15.91 c 0.75,0 1.37,0.16 1.86,0.47 0.49,0.32 0.86,0.81 1.09,1.48 l 5.14,13.42 c 0.79,2.05 1.37,3.84 1.75,5.38 0.37,1.54 0.56,2.9 0.56,4.08 v 15.61 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.93 c -0.79,0 -1.37,-0.17 -1.74,-0.5 -0.38,-0.33 -0.56,-0.88 -0.56,-1.63 v -8.28 h -10.47 v 8.28 z m 3.37,-28.03 -2.78,8.99 h 9.29 l -2.78,-8.99 c -0.16,-0.35 -0.33,-0.61 -0.5,-0.77 -0.18,-0.16 -0.38,-0.24 -0.62,-0.24 h -1.48 c -0.24,0 -0.44,0.08 -0.62,0.24 -0.19,0.16 -0.36,0.42 -0.51,0.77 z"
|
d="m 525.83,537.89 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 H 515 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -15.61 c 0,-1.18 0.19,-2.54 0.56,-4.08 0.38,-1.54 0.96,-3.33 1.75,-5.38 l 5.15,-13.42 c 0.24,-0.67 0.6,-1.16 1.09,-1.48 0.49,-0.31 1.13,-0.47 1.92,-0.47 h 15.91 c 0.75,0 1.37,0.16 1.86,0.47 0.49,0.32 0.86,0.81 1.09,1.48 l 5.14,13.42 c 0.79,2.05 1.37,3.84 1.75,5.38 0.37,1.54 0.56,2.9 0.56,4.08 v 15.61 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.93 c -0.79,0 -1.37,-0.17 -1.74,-0.5 -0.38,-0.33 -0.56,-0.88 -0.56,-1.63 v -8.28 h -10.47 v 8.28 z m 3.37,-28.03 -2.78,8.99 h 9.29 l -2.78,-8.99 c -0.16,-0.35 -0.33,-0.61 -0.5,-0.77 -0.18,-0.16 -0.38,-0.24 -0.62,-0.24 h -1.48 c -0.24,0 -0.44,0.08 -0.62,0.24 -0.19,0.16 -0.36,0.42 -0.51,0.77 z"
|
||||||
id="path12"
|
id="path12"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 570.29,540.02 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 6.15 c 0.75,0 1.39,0.12 1.92,0.35 0.53,0.24 1.05,0.65 1.57,1.24 l 11.47,13.13 v -12.6 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.87 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.27 l -10.11,-12.24 v 19.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.35 -0.88,0.51 -1.63,0.51 z"
|
d="m 570.29,540.02 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 6.15 c 0.75,0 1.39,0.12 1.92,0.35 0.53,0.24 1.05,0.65 1.57,1.24 l 11.47,13.13 v -12.6 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.87 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.27 l -10.11,-12.24 v 19.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.35 -0.88,0.51 -1.63,0.51 z"
|
||||||
id="path14"
|
id="path14"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 641.61,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.17,2.69 -8.96,2.69 z"
|
d="m 641.61,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.17,2.69 -8.96,2.69 z"
|
||||||
id="path16"
|
id="path16"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 683.66,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.57 L 662.9,518.2 c -0.91,-1.22 -1.51,-2.29 -1.8,-3.19 -0.3,-0.91 -0.44,-2.27 -0.44,-4.08 v -11.35 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.11 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 9.7 c 0,0.39 0.02,0.81 0.06,1.24 0.04,0.43 0.2,0.85 0.47,1.24 l 2.72,4.26 c 0.2,0.35 0.4,0.61 0.62,0.77 0.22,0.16 0.48,0.24 0.8,0.24 h 0.59 c 0.31,0 0.58,-0.08 0.8,-0.24 0.22,-0.16 0.42,-0.41 0.62,-0.77 l 2.72,-4.26 c 0.28,-0.39 0.43,-0.81 0.47,-1.24 0.04,-0.43 0.06,-0.85 0.06,-1.24 v -9.7 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.81 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 11.35 c 0,1.81 -0.16,3.17 -0.47,4.08 -0.32,0.91 -0.91,1.97 -1.77,3.19 l -8.99,12.18 v 7.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.35,0.34 -0.9,0.5 -1.64,0.5 z"
|
d="m 683.66,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.57 L 662.9,518.2 c -0.91,-1.22 -1.51,-2.29 -1.8,-3.19 -0.3,-0.91 -0.44,-2.27 -0.44,-4.08 v -11.35 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.11 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 9.7 c 0,0.39 0.02,0.81 0.06,1.24 0.04,0.43 0.2,0.85 0.47,1.24 l 2.72,4.26 c 0.2,0.35 0.4,0.61 0.62,0.77 0.22,0.16 0.48,0.24 0.8,0.24 h 0.59 c 0.31,0 0.58,-0.08 0.8,-0.24 0.22,-0.16 0.42,-0.41 0.62,-0.77 l 2.72,-4.26 c 0.28,-0.39 0.43,-0.81 0.47,-1.24 0.04,-0.43 0.06,-0.85 0.06,-1.24 v -9.7 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.81 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 11.35 c 0,1.81 -0.16,3.17 -0.47,4.08 -0.32,0.91 -0.91,1.97 -1.77,3.19 l -8.99,12.18 v 7.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.35,0.34 -0.9,0.5 -1.64,0.5 z"
|
||||||
id="path18"
|
id="path18"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 725.88,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
d="m 725.88,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||||
id="path20"
|
id="path20"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 766.44,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -29.04 h -8.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.16 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 31.22 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.16 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 h -8.69 v 29.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 z"
|
d="m 766.44,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -29.04 h -8.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.16 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 31.22 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.16 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 h -8.69 v 29.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 z"
|
||||||
id="path22"
|
id="path22"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 817.06,540.02 h -27.44 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 27.44 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -16.32 v 4.55 h 11.53 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.33 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.33 -0.88,0.5 -1.63,0.5 h -11.53 v 5.08 h 16.32 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 z"
|
d="m 817.06,540.02 h -27.44 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 27.44 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -16.32 v 4.55 h 11.53 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.33 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.33 -0.88,0.5 -1.63,0.5 h -11.53 v 5.08 h 16.32 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 z"
|
||||||
id="path24"
|
id="path24"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 839.48,540.02 h -8.81 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.52 c 0.63,0 1.15,0.14 1.57,0.41 0.41,0.28 0.8,0.73 1.15,1.36 l 5.32,9.64 c 0.2,0.35 0.36,0.61 0.5,0.77 0.14,0.16 0.33,0.24 0.56,0.24 h 0.53 c 0.24,0 0.42,-0.08 0.56,-0.24 0.14,-0.16 0.3,-0.41 0.5,-0.77 l 5.26,-9.64 c 0.36,-0.63 0.74,-1.08 1.15,-1.36 0.41,-0.28 0.94,-0.41 1.57,-0.41 h 9.58 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -20.82 l -3.49,6.45 c -0.35,0.67 -0.78,1.15 -1.27,1.45 -0.49,0.3 -1.12,0.44 -1.86,0.44 h -2.37 c -0.75,0 -1.37,-0.15 -1.86,-0.44 -0.49,-0.29 -0.92,-0.78 -1.27,-1.45 l -3.49,-6.45 v 20.82 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.61,0.5 z"
|
d="m 839.48,540.02 h -8.81 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.52 c 0.63,0 1.15,0.14 1.57,0.41 0.41,0.28 0.8,0.73 1.15,1.36 l 5.32,9.64 c 0.2,0.35 0.36,0.61 0.5,0.77 0.14,0.16 0.33,0.24 0.56,0.24 h 0.53 c 0.24,0 0.42,-0.08 0.56,-0.24 0.14,-0.16 0.3,-0.41 0.5,-0.77 l 5.26,-9.64 c 0.36,-0.63 0.74,-1.08 1.15,-1.36 0.41,-0.28 0.94,-0.41 1.57,-0.41 h 9.58 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -20.82 l -3.49,6.45 c -0.35,0.67 -0.78,1.15 -1.27,1.45 -0.49,0.3 -1.12,0.44 -1.86,0.44 h -2.37 c -0.75,0 -1.37,-0.15 -1.86,-0.44 -0.49,-0.29 -0.92,-0.78 -1.27,-1.45 l -3.49,-6.45 v 20.82 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.61,0.5 z"
|
||||||
id="path26"
|
id="path26"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
<path
|
<path
|
||||||
d="m 900.86,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
d="m 900.86,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||||
id="path28"
|
id="path28"
|
||||||
style="fill:#ffffff;fill-opacity:1" />
|
style="fill:#dedede;fill-opacity:1" />
|
||||||
</g>
|
</g>
|
||||||
<g
|
<g
|
||||||
id="g34"
|
id="g34"
|
||||||
@@ -104,7 +109,10 @@
|
|||||||
</g>
|
</g>
|
||||||
<g
|
<g
|
||||||
id="g42"
|
id="g42"
|
||||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)">
|
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
||||||
|
inkscape:export-filename="./g42.svg"
|
||||||
|
inkscape:export-xdpi="169.6163"
|
||||||
|
inkscape:export-ydpi="169.6163">
|
||||||
<path
|
<path
|
||||||
class="st0"
|
class="st0"
|
||||||
d="m 399.59,672.5 c -88.63,0 -160.73,-72.11 -160.73,-160.73 0,-88.62 72.11,-160.73 160.73,-160.73 75.66,0 139.05,52.63 156.04,123.16 h 5.06 C 543.61,400.93 478,346.1 399.6,346.1 c -91.36,0 -165.68,74.32 -165.68,165.68 0,91.36 74.32,165.68 165.68,165.68 56.18,0 105.83,-28.17 135.77,-71.07 -1.21,-1.21 -2.42,-2.42 -3.64,-3.63 -29,42.03 -77.32,69.74 -132.14,69.74 z"
|
d="m 399.59,672.5 c -88.63,0 -160.73,-72.11 -160.73,-160.73 0,-88.62 72.11,-160.73 160.73,-160.73 75.66,0 139.05,52.63 156.04,123.16 h 5.06 C 543.61,400.93 478,346.1 399.6,346.1 c -91.36,0 -165.68,74.32 -165.68,165.68 0,91.36 74.32,165.68 165.68,165.68 56.18,0 105.83,-28.17 135.77,-71.07 -1.21,-1.21 -2.42,-2.42 -3.64,-3.63 -29,42.03 -77.32,69.74 -132.14,69.74 z"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,15 +1,16 @@
|
|||||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
/* 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";
|
"use strict";
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────
|
// ── 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 = {
|
const ROLE_SKIP_STEPS = {
|
||||||
"desktop": [2, 3],
|
"desktop": [3, 4],
|
||||||
"node": [2, 3],
|
"node": [3, 4],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Role state (loaded at init) ───────────────────────────────────
|
// ── Role state (loaded at init) ───────────────────────────────────
|
||||||
@@ -32,6 +33,7 @@ const DOMAIN_DEFS = [
|
|||||||
var _currentStep = 1;
|
var _currentStep = 1;
|
||||||
var _servicesData = null;
|
var _servicesData = null;
|
||||||
var _domainsData = null;
|
var _domainsData = null;
|
||||||
|
var _migrationOccurred = false;
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,6 +66,48 @@ function setStatus(elId, msg, type) {
|
|||||||
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + 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 ────────────────────────────────────
|
// ── Progress / step navigation ────────────────────────────────────
|
||||||
|
|
||||||
function updateProgress(step) {
|
function updateProgress(step) {
|
||||||
@@ -91,6 +135,8 @@ function showStep(step) {
|
|||||||
// Lazy-load step content
|
// Lazy-load step content
|
||||||
if (step === 2) loadStep2();
|
if (step === 2) loadStep2();
|
||||||
if (step === 3) loadStep3();
|
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
|
// Return the next step number, skipping over role-excluded steps
|
||||||
@@ -119,10 +165,157 @@ async function loadStep1() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: Domain Configuration ─────────────────────────────────
|
// ── Step 2: Timezone & Locale ─────────────────────────────────────
|
||||||
|
|
||||||
async function loadStep2() {
|
async function loadStep2() {
|
||||||
var body = document.getElementById("step-2-body");
|
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;
|
if (!body) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -140,8 +333,6 @@ async function loadStep2() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
|
|
||||||
|
|
||||||
// Build set of enabled service units
|
// Build set of enabled service units
|
||||||
var enabledUnits = new Set();
|
var enabledUnits = new Set();
|
||||||
(_servicesData || []).forEach(function(svc) {
|
(_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>';
|
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||||
} else {
|
} else {
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
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;">'
|
+ '<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>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>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>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
|
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
|
||||||
+ '<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>'
|
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
||||||
+ '<li>Njal.la will give you a curl command like:<br>'
|
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
|
||||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>'
|
+ '(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>.)<br>'
|
||||||
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
|
+ '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>'
|
+ '</ol>'
|
||||||
+ '</div>';
|
+ '</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) {
|
relevantDomains.forEach(function(d) {
|
||||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||||
html += '<div class="onboarding-domain-group">';
|
html += '<div class="onboarding-domain-group">';
|
||||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
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 += '<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 += '<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 += '<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>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -189,13 +388,82 @@ async function loadStep2() {
|
|||||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
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 += '<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 += '<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>';
|
html += '</div>';
|
||||||
|
|
||||||
body.innerHTML = html;
|
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() {
|
async function saveStep3() {
|
||||||
setStatus("step-2-status", "Saving domains…", "info");
|
setStatus("step-3-status", "Saving domains…", "info");
|
||||||
var errors = [];
|
var errors = [];
|
||||||
|
|
||||||
// Save each domain input
|
// Save each domain input
|
||||||
@@ -235,20 +503,20 @@ async function saveStep2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setStatus("step-2-status", "⚠ Some errors: " + errors.join("; "), "error");
|
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("step-2-status", "✓ Saved", "ok");
|
setStatus("step-3-status", "✓ Saved", "ok");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 3: Port Forwarding ───────────────────────────────────────
|
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
||||||
|
|
||||||
async function loadStep3() {
|
async function loadStep4() {
|
||||||
var body = document.getElementById("step-3-body");
|
var body = document.getElementById("step-4-body");
|
||||||
if (!body) return;
|
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;
|
var networkData = null;
|
||||||
|
|
||||||
@@ -259,51 +527,59 @@ async function loadStep3() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
|
||||||
|
var internalIp = trimmedInternalIp || "";
|
||||||
var ip = escHtml(internalIp);
|
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;">'
|
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>'
|
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||||
+ '</p>';
|
+ '</p>';
|
||||||
|
|
||||||
html += '<div class="onboarding-port-ip">';
|
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 += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
|
||||||
|
|
||||||
// Required ports table
|
// Required ports table
|
||||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
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 += '<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 += '<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">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">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 += '<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 += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Optional ports table
|
// Optional ports table
|
||||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
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 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);">These 5 additional port openings are required on top of the 4 required ports above.</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 += '<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 += '<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">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">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">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 += '</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>';
|
html += '</div>';
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
html += '<div class="onboarding-port-totals">';
|
html += '<div class="onboarding-port-totals">';
|
||||||
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
|
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
|
||||||
html += '<strong>Total port openings: 9</strong> (with Element Calling — 4 required + 5 optional)';
|
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
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>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>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>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>Set both internal and external port to the same number</li>'
|
||||||
+ '<li>Save and apply changes</li>'
|
+ '<li>Save and apply changes</li>'
|
||||||
+ '</ol>'
|
+ '</ol>'
|
||||||
+ '</details>';
|
+ '</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;
|
body.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4: Complete ──────────────────────────────────────────────
|
// ── Step 5: Complete ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function completeOnboarding() {
|
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…"; }
|
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -345,28 +625,57 @@ async function completeOnboarding() {
|
|||||||
// ── Event wiring ──────────────────────────────────────────────────
|
// ── Event wiring ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function wireNavButtons() {
|
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");
|
var s1next = document.getElementById("step-1-next");
|
||||||
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
|
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");
|
var s2next = document.getElementById("step-2-next");
|
||||||
if (s2next) s2next.addEventListener("click", async function() {
|
if (s2next) s2next.addEventListener("click", async function() {
|
||||||
s2next.disabled = true;
|
s2next.disabled = true;
|
||||||
|
var origText = s2next.textContent;
|
||||||
s2next.textContent = "Saving…";
|
s2next.textContent = "Saving…";
|
||||||
await saveStep2();
|
var ok = await saveStep2();
|
||||||
s2next.disabled = false;
|
s2next.disabled = false;
|
||||||
s2next.textContent = "Save & Continue →";
|
s2next.textContent = origText;
|
||||||
showStep(nextStep(2));
|
if (ok) showStep(nextStep(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 3 → 4 (Complete)
|
// Step 3 → 4 (save domains first)
|
||||||
var s3next = document.getElementById("step-3-next");
|
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
|
// Step 4 → 5 (port forwarding — no save needed)
|
||||||
var s4finish = document.getElementById("step-4-finish");
|
var s4next = document.getElementById("step-4-next");
|
||||||
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
|
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
|
// Back buttons
|
||||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||||
@@ -394,6 +703,17 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
wireNavButtons();
|
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();
|
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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sovran_SystemsOS Hub</title>
|
<title>Sovran_SystemsOS Hub</title>
|
||||||
<link rel="stylesheet" href="/static/css/base.css" />
|
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/header.css" />
|
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/tiles.css" />
|
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/features.css" />
|
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/support.css" />
|
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" />
|
||||||
|
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
<header class="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>
|
<span class="title">Sovran_SystemsOS Hub</span>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<span class="role-badge" id="role-badge">Loading…</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
<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>
|
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +165,7 @@
|
|||||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
<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>
|
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +186,7 @@
|
|||||||
<div class="upgrade-info-box">
|
<div class="upgrade-info-box">
|
||||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
<p class="upgrade-info-title">⚠ What you should know:</p>
|
||||||
<ul class="upgrade-info-list">
|
<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>
|
<li>Some services require ports to be opened on your router</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,33 +212,103 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reboot overlay -->
|
<!-- Security Reset overlay -->
|
||||||
<div class="reboot-overlay" id="reboot-overlay">
|
<div class="security-reset-overlay" id="security-reset-overlay">
|
||||||
<div class="reboot-card">
|
<!-- Phase 1: wiping in progress -->
|
||||||
<div class="reboot-icon">↻</div>
|
<div class="reboot-card" id="security-reset-phase1">
|
||||||
<h2 class="reboot-title">System Rebooting</h2>
|
<div class="security-reset-overlay-icon">🛡</div>
|
||||||
|
<h2 class="reboot-title">Security Reset In Progress</h2>
|
||||||
<p class="reboot-message">
|
<p class="reboot-message">
|
||||||
Sovran_SystemsOS is now restarting.<br />
|
⚠️ Wiping all data and credentials.<br />
|
||||||
This page will automatically reconnect once the system is back online.
|
<strong>Do not power off your computer.</strong><br />
|
||||||
|
This may take several minutes.
|
||||||
</p>
|
</p>
|
||||||
<div class="reboot-dots">
|
<div class="reboot-dots">
|
||||||
<span class="reboot-dot"></span>
|
<span class="reboot-dot"></span>
|
||||||
<span class="reboot-dot"></span>
|
<span class="reboot-dot"></span>
|
||||||
<span class="reboot-dot"></span>
|
<span class="reboot-dot"></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/constants.js"></script>
|
<!-- Reboot overlay -->
|
||||||
<script src="/static/js/state.js"></script>
|
<div class="reboot-overlay" id="reboot-overlay">
|
||||||
<script src="/static/js/helpers.js"></script>
|
<!-- Normal restarting card -->
|
||||||
<script src="/static/js/tiles.js"></script>
|
<div class="reboot-card" id="reboot-main-card">
|
||||||
<script src="/static/js/service-detail.js"></script>
|
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||||
<script src="/static/js/support.js"></script>
|
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||||
<script src="/static/js/update.js"></script>
|
<p class="reboot-message">
|
||||||
<script src="/static/js/rebuild.js"></script>
|
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||||
<script src="/static/js/features.js"></script>
|
This page will reconnect automatically when Sovran_SystemsOS is back online.
|
||||||
<script src="/static/js/events.js"></script>
|
</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>
|
</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">
|
<div class="onboarding-shell">
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- 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 class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,11 +34,40 @@
|
|||||||
<span class="onboarding-step-dot" data-step="3">3</span>
|
<span class="onboarding-step-dot" data-step="3">3</span>
|
||||||
<span class="onboarding-step-connector"></span>
|
<span class="onboarding-step-connector"></span>
|
||||||
<span class="onboarding-step-dot" data-step="4">4</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>
|
</div>
|
||||||
|
|
||||||
<!-- Step panels -->
|
<!-- Step panels -->
|
||||||
<div class="onboarding-panel-wrap">
|
<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 ── -->
|
<!-- ── Step 1: Welcome ── -->
|
||||||
<div class="onboarding-panel" id="step-1">
|
<div class="onboarding-panel" id="step-1">
|
||||||
<div class="onboarding-hero">
|
<div class="onboarding-hero">
|
||||||
@@ -70,20 +99,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 2: Domain Configuration ── -->
|
<!-- ── Step 2: Timezone & Locale ── -->
|
||||||
<div class="onboarding-panel" id="step-2" style="display:none">
|
<div class="onboarding-panel" id="step-2" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌍</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Timezone & Locale</h2>
|
||||||
<p class="onboarding-step-desc">
|
<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.
|
Select your timezone and preferred language so your system clock, logs,
|
||||||
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.
|
and services display the correct time and format.
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card onboarding-card--scroll" id="step-2-body">
|
<div class="onboarding-card" id="step-2-body">
|
||||||
<p class="onboarding-loading">Loading service information…</p>
|
<p class="onboarding-loading">Loading timezone data…</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="step-2-status" class="onboarding-save-status"></div>
|
<div id="step-2-status" class="onboarding-save-status"></div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
@@ -94,29 +121,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 3: Port Forwarding ── -->
|
<!-- ── Step 3: Domain Configuration ── -->
|
||||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🔌</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||||
<p class="onboarding-step-desc">
|
<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.
|
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.
|
||||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card onboarding-card--ports" id="step-3-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
<p class="onboarding-loading">Checking ports…</p>
|
<p class="onboarding-loading">Loading service information…</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="step-3-status" class="onboarding-save-status"></div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
<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">
|
<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 →
|
Continue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 4: Complete ── -->
|
<!-- ── Step 5: Complete ── -->
|
||||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||||
<div class="onboarding-hero">
|
<div class="onboarding-hero">
|
||||||
<div class="onboarding-logo">✅</div>
|
<div class="onboarding-logo">✅</div>
|
||||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
<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.
|
monitor your services, manage credentials, and make changes at any time.
|
||||||
</p>
|
</p>
|
||||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||||
|
<li>✅ Timezone & locale configured</li>
|
||||||
<li>✅ Domain configuration saved</li>
|
<li>✅ Domain configuration saved</li>
|
||||||
<li>✅ Port forwarding reviewed</li>
|
<li>✅ Port forwarding reviewed</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||||
<button class="btn btn-primary" id="step-4-finish">
|
<button class="btn btn-primary" id="step-5-finish">
|
||||||
Go to Dashboard →
|
Go to Dashboard →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,4 +196,4 @@
|
|||||||
|
|
||||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||||
</body>
|
</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,193 @@
|
|||||||
|
"""Structural regression tests for modules/core/local-domain-loopback.nix.
|
||||||
|
|
||||||
|
Verifies that the sovran-hosts-update helper:
|
||||||
|
- is built as a pkgs.writeShellApplication with explicit runtimeInputs
|
||||||
|
(gawk, gnugrep, coreutils);
|
||||||
|
- uses ``lib.getExe hostsUpdateScript`` for both the systemd ExecStart and
|
||||||
|
the activation script so that both contexts share the same Nix-store
|
||||||
|
executable;
|
||||||
|
- does NOT point ExecStart at the raw /etc path;
|
||||||
|
- includes element-calling in the supported domain list;
|
||||||
|
- uses ``awk -v`` for safe marker-variable passing rather than interpolating
|
||||||
|
marker text directly into the awk program;
|
||||||
|
- retains the domain validation regex (idempotency / injection prevention);
|
||||||
|
- exposes /etc/sovran-hosts-update.sh as a symlink via ``source =`` (not a
|
||||||
|
second raw ``text =`` body);
|
||||||
|
- does NOT rely on environment.systemPackages for the helper's dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NIX_STRING_INDENT = 6
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
FLAKE_SOURCE = (REPO_ROOT / "flake.nix").read_text()
|
||||||
|
PRIMARY_NIXOS_CONFIGURATION_MATCH = re.search(
|
||||||
|
r"nixosConfigurations\.([A-Za-z0-9_-]+)\s*=",
|
||||||
|
FLAKE_SOURCE,
|
||||||
|
)
|
||||||
|
if PRIMARY_NIXOS_CONFIGURATION_MATCH is None:
|
||||||
|
raise RuntimeError("Could not determine the primary nixosConfigurations entry from flake.nix")
|
||||||
|
PRIMARY_NIXOS_CONFIGURATION = PRIMARY_NIXOS_CONFIGURATION_MATCH.group(1)
|
||||||
|
HELPER_BUILD_ATTR = (
|
||||||
|
f'.#nixosConfigurations.{PRIMARY_NIXOS_CONFIGURATION}.config.environment.etc.'
|
||||||
|
'"sovran-hosts-update.sh".source'
|
||||||
|
)
|
||||||
|
NIX_FILE = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "modules"
|
||||||
|
/ "core"
|
||||||
|
/ "local-domain-loopback.nix"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalDomainLoopbackNixStructureTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = NIX_FILE.read_text()
|
||||||
|
|
||||||
|
def _helper_script(self) -> str:
|
||||||
|
start = self.source.index("text = ''") + len("text = ''")
|
||||||
|
end = self.source.index(" '';", start)
|
||||||
|
return "\n".join(
|
||||||
|
line[NIX_STRING_INDENT:]
|
||||||
|
if line.startswith(" " * NIX_STRING_INDENT)
|
||||||
|
else line
|
||||||
|
for line in self.source[start:end].splitlines()
|
||||||
|
).lstrip("\n")
|
||||||
|
|
||||||
|
# ── writeShellApplication and explicit runtimeInputs ────────────────────
|
||||||
|
|
||||||
|
def test_uses_write_shell_application(self):
|
||||||
|
self.assertIn("pkgs.writeShellApplication", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_includes_coreutils(self):
|
||||||
|
self.assertIn("pkgs.coreutils", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_includes_gawk(self):
|
||||||
|
self.assertIn("pkgs.gawk", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_includes_gnugrep(self):
|
||||||
|
self.assertIn("pkgs.gnugrep", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_block_present(self):
|
||||||
|
self.assertIn("runtimeInputs", self.source)
|
||||||
|
|
||||||
|
# ── Both execution paths use lib.getExe ─────────────────────────────────
|
||||||
|
|
||||||
|
def test_exec_start_uses_lib_get_exe(self):
|
||||||
|
"""systemd ExecStart must reference the Nix-store executable."""
|
||||||
|
self.assertIn("ExecStart = lib.getExe hostsUpdateScript", self.source)
|
||||||
|
|
||||||
|
def test_activation_script_uses_lib_get_exe(self):
|
||||||
|
"""Activation text must call the same Nix-store executable."""
|
||||||
|
self.assertIn("${lib.getExe hostsUpdateScript}", self.source)
|
||||||
|
|
||||||
|
def test_exec_start_does_not_point_to_etc_path(self):
|
||||||
|
"""ExecStart must NOT use the raw /etc path (which lacks a deterministic PATH)."""
|
||||||
|
self.assertNotIn('ExecStart = "/etc/sovran-hosts-update.sh"', self.source)
|
||||||
|
|
||||||
|
# ── /etc symlink uses source =, not a second text = body ────────────────
|
||||||
|
|
||||||
|
def test_etc_entry_uses_source_not_text(self):
|
||||||
|
"""The /etc/sovran-hosts-update.sh entry must be a symlink (source =),
|
||||||
|
not a second raw script body (text =)."""
|
||||||
|
self.assertIn(
|
||||||
|
'environment.etc."sovran-hosts-update.sh".source', self.source
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_etc_source_points_to_get_exe(self):
|
||||||
|
self.assertIn(
|
||||||
|
'environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript',
|
||||||
|
self.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── element-calling domain is supported ─────────────────────────────────
|
||||||
|
|
||||||
|
def test_element_calling_domain_key_present(self):
|
||||||
|
self.assertIn("element-calling", self.source)
|
||||||
|
|
||||||
|
# ── Robust awk -v variable passing ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_awk_uses_dash_v_for_begin_marker(self):
|
||||||
|
"""awk must receive the begin marker via -v, not by shell interpolation."""
|
||||||
|
self.assertIn('awk -v begin=', self.source)
|
||||||
|
|
||||||
|
def test_awk_uses_dash_v_for_end_marker(self):
|
||||||
|
self.assertIn('-v end=', self.source)
|
||||||
|
|
||||||
|
def test_awk_does_not_interpolate_marker_into_program(self):
|
||||||
|
"""The old pattern interpolated $BEGIN_MARKER directly into the awk source."""
|
||||||
|
self.assertNotIn('/$BEGIN_MARKER', self.source)
|
||||||
|
self.assertNotIn('/$END_MARKER', self.source)
|
||||||
|
|
||||||
|
# ── Domain validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_domain_validation_regex_present(self):
|
||||||
|
"""The hostname validation regex must still be present for injection prevention."""
|
||||||
|
self.assertIn("grep -qE", self.source)
|
||||||
|
self.assertIn("[a-zA-Z0-9]", self.source)
|
||||||
|
|
||||||
|
def test_invalid_domain_warning_present(self):
|
||||||
|
self.assertIn("skipping invalid domain value", self.source)
|
||||||
|
|
||||||
|
# ── No environment.systemPackages reliance ───────────────────────────────
|
||||||
|
|
||||||
|
def test_no_environment_system_packages_for_helper(self):
|
||||||
|
"""The helper's tools are declared via runtimeInputs; the module must
|
||||||
|
not add them to environment.systemPackages."""
|
||||||
|
self.assertNotIn("environment.systemPackages", self.source)
|
||||||
|
|
||||||
|
# ── Idempotency: existing Sovran block is removed before rewriting ───────
|
||||||
|
|
||||||
|
def test_existing_block_removal_logic_present(self):
|
||||||
|
"""awk strip of the managed block must be present for idempotency."""
|
||||||
|
self.assertIn("skip=1", self.source)
|
||||||
|
self.assertIn("skip=0", self.source)
|
||||||
|
|
||||||
|
def test_managed_block_uses_grouped_append_redirect(self):
|
||||||
|
self.assertIn('} >> "$TMP"', self.source)
|
||||||
|
self.assertEqual(self.source.count('>> "$TMP"'), 1)
|
||||||
|
|
||||||
|
def test_helper_script_passes_shellcheck(self):
|
||||||
|
shellcheck = shutil.which("shellcheck")
|
||||||
|
if shellcheck is None:
|
||||||
|
self.skipTest("shellcheck is not installed")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[shellcheck, "-s", "bash", "-"],
|
||||||
|
input=self._helper_script(),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
output = proc.stdout + proc.stderr
|
||||||
|
self.assertEqual(proc.returncode, 0, output)
|
||||||
|
|
||||||
|
def test_helper_derivation_builds_when_nix_available(self):
|
||||||
|
nix = shutil.which("nix")
|
||||||
|
if nix is None:
|
||||||
|
self.skipTest("nix is not installed")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
nix,
|
||||||
|
"build",
|
||||||
|
HELPER_BUILD_ATTR,
|
||||||
|
"--no-link",
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||||
|
|
||||||
|
# ── Activation script warns on failure rather than silently swallowing ───
|
||||||
|
|
||||||
|
def test_activation_script_emits_warning_on_failure(self):
|
||||||
|
self.assertIn("warning: sovran-hosts-update", self.source)
|
||||||
|
|
||||||
|
|
||||||
|
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 = [
|
imports = [
|
||||||
./modules/modules.nix
|
./modules/modules.nix
|
||||||
./iso/branding.nix
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Boot ────────────────────────────────────────────────────
|
# ── Boot ────────────────────────────────────────────────────
|
||||||
@@ -11,6 +10,8 @@
|
|||||||
boot.loader.efi.canTouchEfiVariables = true;
|
boot.loader.efi.canTouchEfiVariables = true;
|
||||||
boot.loader.efi.efiSysMountPoint = "/boot/efi";
|
boot.loader.efi.efiSysMountPoint = "/boot/efi";
|
||||||
boot.kernelPackages = pkgs.linuxPackages_latest;
|
boot.kernelPackages = pkgs.linuxPackages_latest;
|
||||||
|
boot.kernelParams = [ "quiet" "loglevel=3" "rd.systemd.show_status=false" "udev.log_level=3" ];
|
||||||
|
boot.blacklistedKernelModules = [ "rxrpc" ];
|
||||||
|
|
||||||
# ── Filesystems ─────────────────────────────────────────────
|
# ── Filesystems ─────────────────────────────────────────────
|
||||||
fileSystems."/run/media/Second_Drive" = {
|
fileSystems."/run/media/Second_Drive" = {
|
||||||
@@ -25,21 +26,22 @@
|
|||||||
nix.settings = {
|
nix.settings = {
|
||||||
experimental-features = [ "nix-command" "flakes" ];
|
experimental-features = [ "nix-command" "flakes" ];
|
||||||
download-buffer-size = 524288000;
|
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 ──────────────────────────────────────────────
|
# ── 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.hostName = "nixos";
|
||||||
networking.networkmanager.enable = true;
|
networking.networkmanager.enable = true;
|
||||||
networking.firewall.enable = true;
|
networking.firewall.enable = true;
|
||||||
networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ];
|
networking.firewall.allowedUDPPorts = [ 5353 ];
|
||||||
networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ];
|
|
||||||
|
|
||||||
# ── Avahi (mDNS) ───────────────────────────────────────────
|
# ── 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 = {
|
services.avahi = {
|
||||||
enable = true;
|
enable = true;
|
||||||
hostName = "sovransystemsos";
|
hostName = "sovransystemsos";
|
||||||
@@ -48,17 +50,42 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
# ── Locale / Time ──────────────────────────────────────────
|
# ── Locale / Time ──────────────────────────────────────────
|
||||||
time.timeZone = "America/Los_Angeles";
|
time.timeZone = null;
|
||||||
i18n.defaultLocale = "en_US.UTF-8";
|
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 ────────────────────────────────────────────────
|
# ── Desktop ────────────────────────────────────────────────
|
||||||
services.displayManager.gdm.enable = true;
|
services.displayManager.gdm.enable = true;
|
||||||
services.displayManager.gdm.autoSuspend = false;
|
services.displayManager.gdm.autoSuspend = false;
|
||||||
services.displayManager.gdm.wayland = true;
|
|
||||||
services.desktopManager.gnome.enable = true;
|
services.desktopManager.gnome.enable = true;
|
||||||
services.printing.enable = true;
|
services.printing.enable = true;
|
||||||
systemd.enableEmergencyMode = false;
|
systemd.enableEmergencyMode = false;
|
||||||
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
|
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 ──────────────────────────────────────────────────
|
# ── Audio ──────────────────────────────────────────────────
|
||||||
services.pulseaudio.enable = false;
|
services.pulseaudio.enable = false;
|
||||||
@@ -77,16 +104,25 @@
|
|||||||
extraGroups = [ "networkmanager" ];
|
extraGroups = [ "networkmanager" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
services.displayManager.autoLogin.enable = true;
|
services.displayManager.autoLogin.enable = false;
|
||||||
services.displayManager.autoLogin.user = "free";
|
|
||||||
|
|
||||||
# ── Flatpak ────────────────────────────────────────────────
|
# ── Flatpak ────────────────────────────────────────────────
|
||||||
services.flatpak.enable = true;
|
services.flatpak.enable = true;
|
||||||
systemd.services.flatpak-repo = {
|
systemd.services.flatpak-repo = {
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" "nss-lookup.target" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" "nss-lookup.target" ];
|
||||||
path = [ pkgs.flatpak ];
|
path = [ pkgs.flatpak ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "15s";
|
||||||
|
};
|
||||||
|
unitConfig = {
|
||||||
|
StartLimitIntervalSec = 120;
|
||||||
|
StartLimitBurst = 5;
|
||||||
|
};
|
||||||
script = ''
|
script = ''
|
||||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||||
'';
|
'';
|
||||||
@@ -94,9 +130,9 @@
|
|||||||
|
|
||||||
# ── Packages ───────────────────────────────────────────────
|
# ── Packages ───────────────────────────────────────────────
|
||||||
nixpkgs.config.allowUnfree = true;
|
nixpkgs.config.allowUnfree = true;
|
||||||
nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ];
|
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
|
nftables
|
||||||
git wget fish htop btop
|
git wget fish htop btop
|
||||||
gnomeExtensions.transparent-top-bar-adjustable-transparency
|
gnomeExtensions.transparent-top-bar-adjustable-transparency
|
||||||
gnomeExtensions.dash-to-dock
|
gnomeExtensions.dash-to-dock
|
||||||
@@ -109,12 +145,12 @@
|
|||||||
ranger fastfetch gedit openssl pwgen
|
ranger fastfetch gedit openssl pwgen
|
||||||
aspell aspellDicts.en lm_sensors
|
aspell aspellDicts.en lm_sensors
|
||||||
hunspell hunspellDicts.en_US
|
hunspell hunspellDicts.en_US
|
||||||
synadm brave dua bitwarden-desktop
|
synadm brave dua
|
||||||
gparted pv unzip parted screen zenity
|
gparted pv unzip parted screen zenity
|
||||||
libargon2 gnome-terminal libreoffice-fresh
|
libargon2 gnome-terminal libreoffice-fresh
|
||||||
dig firefox element-desktop wp-cli axel
|
dig firefox wp-cli axel
|
||||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
lk-jwt-service livekit-libwebrtc livekit
|
||||||
matrix-synapse age
|
matrix-synapse age onlyoffice-desktopeditors
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Shell ──────────────────────────────────────────────────
|
# ── Shell ──────────────────────────────────────────────────
|
||||||
@@ -160,7 +196,6 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
|||||||
enable = true;
|
enable = true;
|
||||||
systemCronJobs = [
|
systemCronJobs = [
|
||||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
"*/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"
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
# #
|
# #
|
||||||
# Services, features, and roles are managed by the #
|
# Services, features, and roles are managed by the #
|
||||||
# Sovran Hub. Any changes you make through the Hub #
|
# Sovran Hub. Any changes you make through the Hub #
|
||||||
# will appear in the "Hub Managed" section below. #
|
# will appear in a Hub Managed section added #
|
||||||
|
# automatically below. #
|
||||||
# #
|
# #
|
||||||
# If you want to add your own NixOS modules or #
|
# If you want to add your own NixOS modules or #
|
||||||
# configuration, place them here — outside of the #
|
# configuration, place them here — outside of the #
|
||||||
@@ -21,4 +22,20 @@
|
|||||||
|
|
||||||
# ─── Add your custom NixOS configuration below ───────────
|
# ─── 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": {
|
"nodes": {
|
||||||
"bip110": {
|
"btc-clients": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773169138,
|
"lastModified": 1784932759,
|
||||||
"narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=",
|
"narHash": "sha256-44/iCx+wiYukHGhvPm65ppJZ3FZZbp6f9JE5isz8TsA=",
|
||||||
"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=",
|
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "a10dae067da04b7b170eed73efc665d27fc0e0c5",
|
"rev": "8aab86c245ab9a2bea0d72175d6fd663a892af9f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -71,11 +52,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769996383,
|
"lastModified": 1782949081,
|
||||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -106,16 +87,16 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"extra-container": "extra-container",
|
"extra-container": "extra-container",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs_3",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767721199,
|
"lastModified": 1779253922,
|
||||||
"narHash": "sha256-UzRxDiJlopBGPTjyhCdMP+QdTwXK+l+y45urXCyH69A=",
|
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
||||||
"owner": "fort-nix",
|
"owner": "fort-nix",
|
||||||
"repo": "nix-bitcoin",
|
"repo": "nix-bitcoin",
|
||||||
"rev": "5b532698ce9e8bd79b07d77ab4fc60e1a8408f73",
|
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -127,27 +108,26 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772380631,
|
"lastModified": 1782911660,
|
||||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "master",
|
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-25_05": {
|
"nixpkgs-25_05": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767051569,
|
"lastModified": 1767313136,
|
||||||
"narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=",
|
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd",
|
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -159,27 +139,27 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1751274312,
|
"lastModified": 1784856561,
|
||||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
"narHash": "sha256-J+Bx1Z6Oeoj2FgnBhRMKyUhhtDoOpTgXYaVLZpDjW4A=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
"rev": "597283ad8aa0b331c788e97c4c262d58877074ef",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "nixos-24.11",
|
"ref": "nixos-26.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-unstable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767364772,
|
"lastModified": 1778869304,
|
||||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -191,26 +171,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772380631,
|
"lastModified": 1778737229,
|
||||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||||
"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",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -220,13 +185,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775036866,
|
"lastModified": 1784796856,
|
||||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -236,13 +201,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_5": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770380644,
|
"lastModified": 1784555310,
|
||||||
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
|
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
|
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -255,15 +220,15 @@
|
|||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs_5",
|
"nixpkgs": "nixpkgs_4",
|
||||||
"systems": "systems_2"
|
"systems": "systems_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774802402,
|
"lastModified": 1784814601,
|
||||||
"narHash": "sha256-L1UJ/zxKTyyaGGmytH6OYlgQ0HGSMhvPkvU+iz4Mkb8=",
|
"narHash": "sha256-T32JXjZ7kIbhBn8/Har171yGg6IBdl97cxAWameqZDE=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "cbd8536a05d1aae2593cb5c9ace1010c8c5845cb",
|
"rev": "f316e949e0ed9df0e1e0bf645c6dce721d4e230e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -272,28 +237,11 @@
|
|||||||
"type": "github"
|
"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": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"bip110": "bip110",
|
|
||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nix-bitcoin": "nix-bitcoin",
|
"nix-bitcoin": "nix-bitcoin",
|
||||||
"nixpkgs": "nixpkgs_4",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim"
|
||||||
}
|
}
|
||||||
@@ -315,15 +263,16 @@
|
|||||||
},
|
},
|
||||||
"systems_2": {
|
"systems_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1681028828,
|
"lastModified": 1774449309,
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
"owner": "nix-systems",
|
"owner": "nix-systems",
|
||||||
"repo": "default",
|
"repo": "default",
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-systems",
|
"owner": "nix-systems",
|
||||||
|
"ref": "future-26.11",
|
||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
|
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -25,27 +24,17 @@
|
|||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||||
self.nixosModules.Sovran_SystemsOS
|
self.nixosModules.Sovran_SystemsOS
|
||||||
/etc/nixos/hardware-configuration.nix
|
./hardware-configuration.nix
|
||||||
/etc/nixos/role-state.nix
|
./role-state.nix
|
||||||
/etc/nixos/custom.nix
|
./custom.nix
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
nixosConfigurations.sovran-iso-desktop = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||||
./iso/desktop.nix
|
./iso/common.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
|
|
||||||
nix-bitcoin.nixosModules.default
|
nix-bitcoin.nixosModules.default
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
@@ -66,7 +55,6 @@
|
|||||||
btc-clients.packages.${pkgs.system}.bisq2
|
btc-clients.packages.${pkgs.system}.bisq2
|
||||||
btc-clients.packages.${pkgs.system}.sparrow
|
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 = [
|
imports = [
|
||||||
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
|
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
|
||||||
./branding.nix
|
|
||||||
];
|
];
|
||||||
|
|
||||||
image.baseName = lib.mkForce "Sovran_SystemsOS";
|
image.baseName = lib.mkForce "Sovran_SystemsOS";
|
||||||
@@ -62,12 +61,118 @@ in
|
|||||||
nixos-install-tools
|
nixos-install-tools
|
||||||
git
|
git
|
||||||
curl
|
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/logo.png".source = ./assets/splash-logo.png;
|
||||||
environment.etc."sovran/flake".source = sovranSource;
|
environment.etc."sovran/flake".source = sovranSource;
|
||||||
environment.etc."sovran/installer.py".source = ./installer.py;
|
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 = ''
|
environment.etc."xdg/autostart/sovran-installer.desktop".text = ''
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ gi.require_version("Adw", "1")
|
|||||||
from gi.repository import Gtk, Adw, GLib
|
from gi.repository import Gtk, Adw, GLib
|
||||||
import atexit
|
import atexit
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -14,6 +16,48 @@ LOGO = "/etc/sovran/logo.png"
|
|||||||
LOG = "/tmp/sovran-install.log"
|
LOG = "/tmp/sovran-install.log"
|
||||||
FLAKE = "/etc/sovran/flake"
|
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:
|
try:
|
||||||
logfile = open(LOG, "a")
|
logfile = open(LOG, "a")
|
||||||
atexit.register(logfile.close)
|
atexit.register(logfile.close)
|
||||||
@@ -87,7 +131,7 @@ def symbolic_icon(name):
|
|||||||
return icon
|
return icon
|
||||||
|
|
||||||
|
|
||||||
# ── Application ────────────────────────────────────────────────────────────────
|
# ── Application ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class InstallerApp(Adw.Application):
|
class InstallerApp(Adw.Application):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -99,7 +143,7 @@ class InstallerApp(Adw.Application):
|
|||||||
self.win.present()
|
self.win.present()
|
||||||
|
|
||||||
|
|
||||||
# ── Main Window ────────────────────────────────────────────────────────────────
|
# ── Main Window ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class InstallerWindow(Adw.ApplicationWindow):
|
class InstallerWindow(Adw.ApplicationWindow):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -113,6 +157,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
self.boot_size = None
|
self.boot_size = None
|
||||||
self.data_disk = None
|
self.data_disk = None
|
||||||
self.data_size = None
|
self.data_size = None
|
||||||
|
self.data_drive_has_timechain = False
|
||||||
|
self.free_password = None
|
||||||
|
|
||||||
# Root navigation view
|
# Root navigation view
|
||||||
self.nav = Adw.NavigationView()
|
self.nav = Adw.NavigationView()
|
||||||
@@ -146,7 +192,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
break
|
break
|
||||||
self.push_page(title, child)
|
self.push_page(title, child)
|
||||||
|
|
||||||
# ── Shared widgets ───────────────��─────────────────────────────────────
|
# ── Shared widgets ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def make_scrolled_log(self):
|
def make_scrolled_log(self):
|
||||||
sw = Gtk.ScrolledWindow()
|
sw = Gtk.ScrolledWindow()
|
||||||
@@ -216,7 +262,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
title = Gtk.Label()
|
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)
|
title.set_margin_top(8)
|
||||||
hero.append(title)
|
hero.append(title)
|
||||||
|
|
||||||
@@ -237,7 +283,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
notice.set_markup(
|
notice.set_markup(
|
||||||
"<span size='medium'>"
|
"<span size='medium'>"
|
||||||
"Before installation begins, please ensure you have an <b>active internet connection</b>.\n"
|
"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."
|
"to complete the process. Connect via <b>Ethernet cable</b> or configure <b>Wi-Fi</b> now."
|
||||||
"</span>"
|
"</span>"
|
||||||
)
|
)
|
||||||
@@ -303,7 +349,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
title = Gtk.Label()
|
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)
|
hero.append(title)
|
||||||
|
|
||||||
sub = Gtk.Label()
|
sub = Gtk.Label()
|
||||||
@@ -317,6 +363,40 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
sep.set_margin_end(40)
|
sep.set_margin_end(40)
|
||||||
outer.append(sep)
|
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 label
|
||||||
role_lbl = Gtk.Label()
|
role_lbl = Gtk.Label()
|
||||||
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
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):
|
def push_disk_confirm(self):
|
||||||
"""Show the selected drives and ask the user to type ERASE to confirm."""
|
"""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)
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
|
|
||||||
# Disk info group
|
# Disk info group
|
||||||
disk_group = Adw.PreferencesGroup()
|
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_top(24)
|
||||||
disk_group.set_margin_start(40)
|
disk_group.set_margin_start(40)
|
||||||
disk_group.set_margin_end(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.set_subtitle(f"/dev/{self.data_disk} — {human_size(self.data_size)}")
|
||||||
data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||||
disk_group.add(data_row)
|
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)
|
outer.append(disk_group)
|
||||||
|
|
||||||
# Warning banner
|
# Warning banner
|
||||||
banner = Adw.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_revealed(True)
|
||||||
banner.set_margin_top(16)
|
banner.set_margin_top(16)
|
||||||
banner.set_margin_start(40)
|
banner.set_margin_start(40)
|
||||||
@@ -731,9 +828,61 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
# ── Worker: partition ─────────────────────────────────────────────────
|
# ── 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):
|
def do_partition(self, buf):
|
||||||
boot_path = f"/dev/{self.boot_disk}"
|
boot_path = f"/dev/{self.boot_disk}"
|
||||||
data_path = f"/dev/{self.data_disk}" if self.data_disk else None
|
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) ──
|
# ── Wipe disk(s) ──
|
||||||
GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n")
|
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", "sgdisk", "--zap-all", boot_path], buf)
|
||||||
run_stream(["sudo", "wipefs", "--all", "--force", 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", "sgdisk", "--zap-all", data_path], buf)
|
||||||
run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf)
|
run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf)
|
||||||
|
|
||||||
run_stream(["sudo", "partprobe", boot_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)
|
run_stream(["sudo", "partprobe", data_path], buf)
|
||||||
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -762,7 +911,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# ── Partition data disk (if selected) ──
|
# ── 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")
|
GLib.idle_add(append_text, buf, "\n=== Partitioning data disk ===\n")
|
||||||
run_stream(["sudo", "sgdisk",
|
run_stream(["sudo", "sgdisk",
|
||||||
"-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary",
|
"-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary",
|
||||||
@@ -773,14 +922,14 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
# ── Format partitions ──
|
# ── Format partitions ──
|
||||||
GLib.idle_add(append_text, buf, "\n=== Formatting partitions ===\n")
|
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_p1 = self.partition_path(boot_path, 1)
|
||||||
boot_p2 = f"{boot_path}p2" if "nvme" in boot_path else f"{boot_path}2"
|
boot_p2 = self.partition_path(boot_path, 2)
|
||||||
|
|
||||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||||
|
|
||||||
if data_path:
|
if data_path and not self.data_drive_has_timechain:
|
||||||
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", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf)
|
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf)
|
||||||
|
|
||||||
# ── Mount filesystems ──
|
# ── Mount filesystems ──
|
||||||
@@ -790,9 +939,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||||
|
|
||||||
if data_path:
|
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", "mkdir", "-p", "/mnt/run/media/Second_Drive"], buf)
|
||||||
run_stream(["sudo", "mount", data_p1, "/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")
|
GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n")
|
||||||
run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf)
|
run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf)
|
||||||
@@ -830,8 +982,9 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}")
|
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", "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):
|
def push_ready(self):
|
||||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
@@ -882,7 +1035,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
outer.append(self.nav_row(
|
outer.append(self.nav_row(
|
||||||
next_label="Install Now",
|
next_label="Install Now",
|
||||||
next_cb=lambda b: self.push_progress(
|
next_cb=lambda b: self.push_progress(
|
||||||
"Installing Sovran SystemsOS",
|
"Installing Sovran_SystemsOS",
|
||||||
"Building and installing your system. Please wait...",
|
"Building and installing your system. Please wait...",
|
||||||
self.do_install
|
self.do_install
|
||||||
)
|
)
|
||||||
@@ -917,57 +1070,111 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
"--impure"
|
"--impure"
|
||||||
], buf)
|
], buf)
|
||||||
|
|
||||||
|
# Clean up /mnt/etc/nixos — only 5 files are needed post-install.
|
||||||
|
# configuration.nix and modules/ were needed during nixos-install
|
||||||
|
# for flake evaluation, but are now baked into the Nix store via
|
||||||
|
# self.nixosModules.Sovran_SystemsOS.
|
||||||
|
GLib.idle_add(append_text, buf, "Cleaning up /mnt/etc/nixos...\n")
|
||||||
|
keep = {"flake.nix", "flake.lock", "hardware-configuration.nix",
|
||||||
|
"role-state.nix", "custom.nix"}
|
||||||
|
nixos_dir = "/mnt/etc/nixos"
|
||||||
|
for entry in os.listdir(nixos_dir):
|
||||||
|
if entry not in keep:
|
||||||
|
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)
|
GLib.idle_add(self.push_complete)
|
||||||
|
|
||||||
# ── Step 6: Complete ───────────────────────────────────────────────────
|
# ── Complete ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def push_complete(self):
|
def push_complete(self):
|
||||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
|
|
||||||
status = Adw.StatusPage()
|
status = Adw.StatusPage()
|
||||||
status.set_title("Installation Complete!")
|
status.set_title("Installation Complete!")
|
||||||
status.set_description("Welcome to Sovran SystemsOS.")
|
status.set_description("Before rebooting, write down your login password.")
|
||||||
status.set_vexpand(True)
|
status.set_icon_name("dialog-password-symbolic")
|
||||||
|
outer.append(status)
|
||||||
|
|
||||||
creds_group = Adw.PreferencesGroup()
|
pw_frame = Gtk.Frame()
|
||||||
creds_group.set_title("⚠ Write down your login details before rebooting")
|
pw_frame.set_margin_start(60)
|
||||||
creds_group.set_margin_start(40)
|
pw_frame.set_margin_end(60)
|
||||||
creds_group.set_margin_end(40)
|
pw_label = Gtk.Label()
|
||||||
|
pw_label.set_markup(
|
||||||
user_row = Adw.ActionRow()
|
f"<span font_family='monospace' size='xx-large' weight='bold'>"
|
||||||
user_row.set_title("Username")
|
f"{GLib.markup_escape_text(self.free_password)}</span>"
|
||||||
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."
|
|
||||||
)
|
)
|
||||||
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)
|
warning = Gtk.Label()
|
||||||
content_box.append(status)
|
warning.set_markup(
|
||||||
content_box.append(creds_group)
|
"<span foreground='#e8a838' size='medium' weight='bold'>"
|
||||||
outer.append(content_box)
|
"⚠ 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")
|
outer.append(Gtk.Label(label="", vexpand=True))
|
||||||
reboot_btn.add_css_class("success")
|
|
||||||
|
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.add_css_class("pill")
|
||||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||||
|
btn_box.append(reboot_btn)
|
||||||
nav = Gtk.Box()
|
outer.append(btn_box)
|
||||||
nav.set_margin_bottom(24)
|
|
||||||
nav.set_margin_end(40)
|
|
||||||
nav.set_halign(Gtk.Align.END)
|
|
||||||
nav.append(reboot_btn)
|
|
||||||
outer.append(nav)
|
|
||||||
|
|
||||||
self.push_page("Complete", outer)
|
self.push_page("Complete", outer)
|
||||||
return False
|
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 = {
|
services.bitcoind = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = config.nix-bitcoin.pkgs.bitcoind-knots;
|
package = pkgs.bitcoind-knots;
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
txindex = true;
|
txindex = true;
|
||||||
tor.proxy = true;
|
tor.proxy = true;
|
||||||
@@ -55,7 +55,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
};
|
};
|
||||||
|
|
||||||
services.btcpayserver = {
|
services.btcpayserver = {
|
||||||
enable = true;
|
enable = config.sovran_systemsOS.web.btcpayserver;
|
||||||
};
|
};
|
||||||
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
services.btcpayserver.lightningBackend = "lnd";
|
||||||
@@ -69,8 +69,48 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
};
|
};
|
||||||
|
|
||||||
nix-bitcoin.useVersionLockedPkgs = false;
|
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"; }
|
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,46 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
exposeBtcpay = config.sovran_systemsOS.web.btcpayserver;
|
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
|
in
|
||||||
{
|
{
|
||||||
services.caddy = {
|
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";
|
user = "caddy";
|
||||||
group = "root";
|
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 = {
|
systemd.services.caddy-generate-config = {
|
||||||
@@ -39,12 +72,20 @@ in
|
|||||||
HAVEN=$(read_domain haven)
|
HAVEN=$(read_domain haven)
|
||||||
ACME_EMAIL=$(read_domain sslemail)
|
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
|
cat > /run/caddy/Caddyfile <<EOF
|
||||||
{
|
{
|
||||||
email $ACME_EMAIL
|
email $ACME_EMAIL
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
'' else ''
|
||||||
|
cat > /run/caddy/Caddyfile <<EOF
|
||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
''}
|
||||||
|
|
||||||
# ── Matrix ──────────────────────────────────────
|
# ── Matrix ──────────────────────────────────────
|
||||||
if [ -n "$MATRIX" ]; then
|
if [ -n "$MATRIX" ]; then
|
||||||
@@ -56,10 +97,10 @@ EOF
|
|||||||
$MATRIX {
|
$MATRIX {
|
||||||
reverse_proxy /_matrix/* http://localhost:8008
|
reverse_proxy /_matrix/* http://localhost:8008
|
||||||
reverse_proxy /_synapse/client/* http://localhost:8008
|
reverse_proxy /_synapse/client/* http://localhost:8008
|
||||||
}
|
handle /.well-known/matrix/server {
|
||||||
|
header Content-Type application/json
|
||||||
$MATRIX:8448 {
|
respond \`{"m.server":"$MATRIX:443"}\` 200
|
||||||
reverse_proxy http://localhost:8008
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
@@ -72,7 +113,7 @@ EOF
|
|||||||
$WORDPRESS {
|
$WORDPRESS {
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
root * /var/lib/www/wordpress
|
root * /var/lib/www/wordpress
|
||||||
php_fastcgi unix//run/phpfpm/mypool.sock
|
php_fastcgi unix//run/phpfpm/wordpress.sock
|
||||||
file_server browse
|
file_server browse
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -85,7 +126,7 @@ EOF
|
|||||||
$NEXTCLOUD {
|
$NEXTCLOUD {
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
root * /var/lib/www/nextcloud
|
root * /var/lib/www/nextcloud
|
||||||
php_fastcgi unix//run/phpfpm/mypool.sock {
|
php_fastcgi unix//run/phpfpm/nextcloud.sock {
|
||||||
trusted_proxies private_ranges
|
trusted_proxies private_ranges
|
||||||
}
|
}
|
||||||
file_server
|
file_server
|
||||||
@@ -150,6 +191,12 @@ EOF
|
|||||||
|
|
||||||
http://sovransystemsos.local {
|
http://sovransystemsos.local {
|
||||||
reverse_proxy localhost:8937
|
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
|
EOF
|
||||||
|
|
||||||
@@ -170,6 +217,11 @@ EOF
|
|||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
}
|
}
|
||||||
EOF
|
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,171 @@
|
|||||||
|
{ 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 wrapped Nix-store executable is reused by both the activation hook
|
||||||
|
# and the ``sovran-hosts-update.service`` unit, ensuring a deterministic runtime
|
||||||
|
# PATH in every execution context.
|
||||||
|
|
||||||
|
let
|
||||||
|
# ── Wrapped Nix-store executable ──────────────────────────────────────────
|
||||||
|
# Built with pkgs.writeShellApplication so that all required runtime tools
|
||||||
|
# (awk, grep, coreutils) are declared explicitly and injected into PATH by
|
||||||
|
# Nix. Both the systemd service and the activation hook reference this same
|
||||||
|
# store-path executable — there is no second raw script body.
|
||||||
|
hostsUpdateScript = pkgs.writeShellApplication {
|
||||||
|
name = "sovran-hosts-update";
|
||||||
|
|
||||||
|
# Declare every external command the script calls. These packages are
|
||||||
|
# added to the script's runtime PATH by writeShellApplication; nothing from
|
||||||
|
# the system PATH is relied upon.
|
||||||
|
runtimeInputs = [
|
||||||
|
pkgs.coreutils # readlink, cp, mv, chmod, mktemp, rm, tr, head
|
||||||
|
pkgs.gawk # awk
|
||||||
|
pkgs.gnugrep # grep
|
||||||
|
];
|
||||||
|
|
||||||
|
text = ''
|
||||||
|
# Regenerate the Sovran-managed loopback block in /etc/hosts.
|
||||||
|
# Safe to run multiple times — idempotent.
|
||||||
|
|
||||||
|
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.
|
||||||
|
# awk -v passes marker strings safely without shell interpolation.
|
||||||
|
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" '
|
||||||
|
$0 == begin { skip=1; next }
|
||||||
|
$0 == end { 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"
|
||||||
|
printf '%s\n' "# These entries route configured service domains to local Caddy."
|
||||||
|
printf '%s\n' "# They are managed automatically — do not edit this block."
|
||||||
|
printf '%s\n' "$ENTRIES"
|
||||||
|
printf '%s\n' "$END_MARKER"
|
||||||
|
} >> "$TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 5: atomically replace /etc/hosts ─────────────────────────────
|
||||||
|
mv "$TMP" "$HOSTS_FILE"
|
||||||
|
chmod 644 "$HOSTS_FILE"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# ── /etc/sovran-hosts-update.sh — operator discoverability symlink ─────────
|
||||||
|
# Retain the familiar /etc path so administrators can inspect or manually
|
||||||
|
# invoke the helper. The target is the wrapped Nix-store executable, so
|
||||||
|
# there is no second raw script body to keep in sync.
|
||||||
|
environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript;
|
||||||
|
|
||||||
|
# ── 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;
|
||||||
|
# Point directly at the wrapped Nix-store executable, not the /etc path.
|
||||||
|
ExecStart = lib.getExe hostsUpdateScript;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 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.
|
||||||
|
# The same wrapped Nix-store executable used by the systemd service is
|
||||||
|
# referenced here, guaranteeing identical runtime dependencies in both
|
||||||
|
# execution contexts.
|
||||||
|
|
||||||
|
system.activationScripts.sovranDomainLoopback = {
|
||||||
|
text = ''
|
||||||
|
if [ -d /var/lib/domains ]; then
|
||||||
|
if ! ${lib.getExe hostsUpdateScript}; then
|
||||||
|
echo "warning: sovran-hosts-update: failed to update /etc/hosts loopback entries" >&2
|
||||||
|
fi
|
||||||
|
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 [
|
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) ─────────────────────────
|
# ── Server+Desktop Role (default) ─────────────────────────
|
||||||
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
|
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
|
||||||
|
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── Desktop Only Role ─────────────────────────────────────
|
# ── Desktop Only Role ─────────────────────────────────────
|
||||||
(lib.mkIf config.sovran_systemsOS.roles.desktop {
|
(lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||||
services.desktopManager.gnome.enable = true;
|
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 = {
|
sovran_systemsOS.services = {
|
||||||
synapse = lib.mkDefault false;
|
synapse = lib.mkForce false;
|
||||||
bitcoin = lib.mkDefault false;
|
bitcoin = lib.mkForce false;
|
||||||
vaultwarden = lib.mkDefault false;
|
vaultwarden = lib.mkForce false;
|
||||||
wordpress = lib.mkDefault false;
|
wordpress = lib.mkForce false;
|
||||||
nextcloud = lib.mkDefault 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 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 {
|
(lib.mkIf config.sovran_systemsOS.roles.node {
|
||||||
sovran_systemsOS.services = {
|
sovran_systemsOS.services = {
|
||||||
bitcoin = lib.mkDefault true;
|
bitcoin = lib.mkDefault true;
|
||||||
@@ -35,7 +52,6 @@
|
|||||||
|
|
||||||
sovran_systemsOS.features = {
|
sovran_systemsOS.features = {
|
||||||
mempool = lib.mkDefault true;
|
mempool = lib.mkDefault true;
|
||||||
bip110 = lib.mkDefault true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||||
|
|||||||
@@ -43,23 +43,44 @@
|
|||||||
# ── Features (default OFF — user can enable in custom.nix) ──
|
# ── Features (default OFF — user can enable in custom.nix) ──
|
||||||
features = {
|
features = {
|
||||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||||
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
|
|
||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||||
sshd = lib.mkEnableOption "SSH remote access";
|
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 exposure (controls Caddy vhosts) ──────────────────
|
||||||
web = {
|
web = {
|
||||||
btcpayserver = lib.mkOption {
|
btcpayserver = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = false;
|
||||||
description = "Expose BTCPay Server via Caddy";
|
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 ─────────────────────────────────
|
# ── Domain setup registry ─────────────────────────────────
|
||||||
domainRequirements = lib.mkOption {
|
domainRequirements = lib.mkOption {
|
||||||
type = lib.types.listOf (lib.types.submodule {
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
@@ -80,4 +101,15 @@
|
|||||||
description = "Nostr public key (npub1...) for Haven relay";
|
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 = [
|
{ 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 — Username"; value = "free"; }
|
||||||
{ label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; }
|
{ label = "Free Account / Hub Login — Password"; file = "/var/lib/secrets/free-password"; }
|
||||||
{ label = "Root Password"; file = "/var/lib/secrets/root-password"; }
|
{ label = "Administrator (root) Password"; file = "/var/lib/secrets/root-password"; }
|
||||||
{ label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; }
|
{ label = "SSH Passphrase — use via: ssh root@localhost"; file = "/var/lib/secrets/ssh-passphrase"; }
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Infrastructure — Caddy + Tor (NOT desktop-only) ────────
|
# ── Infrastructure — Caddy + Tor (NOT desktop-only) ────────
|
||||||
@@ -24,52 +24,47 @@ let
|
|||||||
{ label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; }
|
{ label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; }
|
||||||
{ label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; }
|
{ label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; }
|
||||||
{ label = "Address"; file = "/var/lib/secrets/internal-ip"; suffix = ":3389"; }
|
{ 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) ────────────────────
|
# ── Bitcoin Base (node implementations) ────────────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
{ 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"; 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://"; }
|
||||||
]; }
|
|
||||||
{ 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 Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
{ 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) ─────────────
|
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
{ 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"; }
|
{ label = "Port"; value = "50001"; }
|
||||||
]; }
|
]; }
|
||||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
{ 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 = [
|
{ 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 = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
{ 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 = "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 = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; }
|
||||||
{ label = "Note"; value = "Create your admin account on first visit"; }
|
{ 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 = [
|
{ 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 = "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 or paste the Connection URL"; }
|
{ 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 = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
{ 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 = [
|
{ 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 = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
{ 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) ────────────────────
|
# ── Communication (server+desktop only) ────────────────────
|
||||||
@@ -140,16 +135,27 @@ let
|
|||||||
RC=0
|
RC=0
|
||||||
|
|
||||||
echo "── Step 1/3: nix flake update ────────────────────"
|
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"
|
echo "[ERROR] nix flake update failed"
|
||||||
RC=1
|
RC=1
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ "$RC" -eq 0 ]; then
|
if [ "$RC" -eq 0 ]; then
|
||||||
echo "── Step 2/3: nixos-rebuild switch ──────────────────"
|
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||||
if ! nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
|
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||||
echo "[ERROR] nixos-rebuild switch failed"
|
--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
|
RC=1
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
@@ -165,9 +171,10 @@ let
|
|||||||
|
|
||||||
if [ "$RC" -eq 0 ]; then
|
if [ "$RC" -eq 0 ]; then
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo " ✓ Update completed successfully"
|
echo " ✓ Update staged successfully"
|
||||||
|
echo " Reboot required to activate the new system"
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo "SUCCESS" > "$STATUS"
|
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||||
else
|
else
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo " ✗ Update failed — see errors above"
|
echo " ✗ Update failed — see errors above"
|
||||||
@@ -195,12 +202,34 @@ let
|
|||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Rebuilding system configuration ──────────────"
|
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 "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo " ✓ Rebuild completed successfully"
|
echo " ✓ Rebuild completed successfully"
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo "SUCCESS" > "$STATUS"
|
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
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
@@ -211,9 +240,28 @@ let
|
|||||||
fi
|
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 auto-launch wrapper script ────────────────────────────────
|
||||||
hub-autolaunch-script = pkgs.writeShellScript "sovran-hub-autolaunch.sh" ''
|
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"
|
DISABLE_FLAG="/var/lib/sovran/hub-autolaunch-disabled"
|
||||||
BOOT_FLAG="/run/sovran-hub-autolaunch-done"
|
BOOT_FLAG="/run/sovran-hub-autolaunch-done"
|
||||||
@@ -232,7 +280,7 @@ let
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
xdg-open http://localhost:8937
|
${hub-brave-wrapper}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||||
@@ -242,6 +290,8 @@ let
|
|||||||
|
|
||||||
src = ../../app;
|
src = ../../app;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkgs.librsvg ];
|
||||||
|
|
||||||
propagatedBuildInputs = with pkgs.python3Packages; [
|
propagatedBuildInputs = with pkgs.python3Packages; [
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
@@ -263,7 +313,12 @@ let
|
|||||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||||
|
|
||||||
install -d $out/share/icons/hicolor/scalable/apps
|
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
|
install -d $out/share/applications
|
||||||
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
|
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
|
||||||
@@ -271,11 +326,13 @@ let
|
|||||||
Type=Application
|
Type=Application
|
||||||
Name=Sovran Hub
|
Name=Sovran Hub
|
||||||
Comment=Open Sovran_SystemsOS Hub dashboard
|
Comment=Open Sovran_SystemsOS Hub dashboard
|
||||||
Exec=xdg-open http://localhost:8937
|
Exec=${hub-brave-wrapper}
|
||||||
Icon=sovran-hub
|
Icon=sovran-hub
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=System;
|
Categories=System;
|
||||||
StartupNotify=false
|
StartupNotify=true
|
||||||
|
StartupWMClass=brave-localhost__auto-login-Default
|
||||||
|
X-GNOME-SingleWindow=true
|
||||||
DESKTOP
|
DESKTOP
|
||||||
|
|
||||||
install -d $out/bin
|
install -d $out/bin
|
||||||
@@ -312,21 +369,44 @@ in
|
|||||||
description = "Sovran_SystemsOS Hub Web Interface";
|
description = "Sovran_SystemsOS Hub Web Interface";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
|
conflicts = [ "sovran-hub-reboot.service" ];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${sovran-hub-web}/bin/sovran-hub-web";
|
ExecStart = "${sovran-hub-web}/bin/sovran-hub-web";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
|
RestartPreventExitStatus = "SIGTERM";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
User = "root";
|
User = "root";
|
||||||
StandardOutput = "journal";
|
StandardOutput = "journal";
|
||||||
StandardError = "journal";
|
StandardError = "journal";
|
||||||
};
|
};
|
||||||
|
|
||||||
path = [ pkgs.qrencode ];
|
path = [
|
||||||
|
pkgs.bash
|
||||||
|
pkgs.gawk
|
||||||
|
pkgs.qrencode
|
||||||
|
pkgs.curl
|
||||||
|
pkgs.iproute2
|
||||||
|
pkgs.nftables
|
||||||
|
pkgs.iptables
|
||||||
|
pkgs.hostname
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.findutils
|
||||||
|
pkgs.gnugrep
|
||||||
|
pkgs.rsync
|
||||||
|
pkgs.acl
|
||||||
|
pkgs.util-linux
|
||||||
|
]
|
||||||
|
++ lib.optional cfg.services.bitcoin config.services.bitcoind.package
|
||||||
|
++ lib.optionals cfg.services.bitcoin [ pkgs.lnd ]
|
||||||
|
++ lib.optionals (cfg.services.nextcloud || cfg.services.synapse) [ config.services.postgresql.package ]
|
||||||
|
++ lib.optionals config.services.mysql.enable [ config.services.mysql.package ];
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.sovran-hub-update = {
|
systemd.services.sovran-hub-update = {
|
||||||
description = "Sovran_SystemsOS System 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 = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
ExecStart = "${update-script}";
|
ExecStart = "${update-script}";
|
||||||
@@ -335,15 +415,25 @@ in
|
|||||||
|
|
||||||
systemd.services.sovran-hub-rebuild = {
|
systemd.services.sovran-hub-rebuild = {
|
||||||
description = "Sovran_SystemsOS System 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 = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
ExecStart = "${rebuild-script}";
|
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 ];
|
environment.systemPackages = [ sovran-hub-web ];
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 3051 8937 60847 ];
|
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
|
||||||
|
|
||||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
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
|
let
|
||||||
|
|
||||||
|
wallpaperSrc = ../../assets/wallpapers;
|
||||||
|
|
||||||
customWallpaper = pkgs.stdenvNoCC.mkDerivation {
|
customWallpaper = pkgs.stdenvNoCC.mkDerivation {
|
||||||
pname = "sovran-systemsos-wallpaper";
|
pname = "sovran-systemsos-wallpaper";
|
||||||
version = "1.0";
|
version = "2.0";
|
||||||
src = pkgs.fetchurl {
|
src = wallpaperSrc;
|
||||||
url = "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS_iso/raw/branch/main/post-install-scripts/Wallpaper_Dark_Wide.png";
|
nativeBuildInputs = [ pkgs.librsvg ];
|
||||||
sha256 = "0609gy0vp92fywl7pcr4y3mg05ca6pwxsnlsax14jd371fj4y7fn";
|
|
||||||
};
|
|
||||||
dontUnpack = true;
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/share/backgrounds/sovran
|
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
|
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;
|
programs.dconf.enable = true;
|
||||||
|
|
||||||
@@ -29,8 +200,8 @@ in
|
|||||||
settings = {
|
settings = {
|
||||||
|
|
||||||
"org/gnome/desktop/background" = {
|
"org/gnome/desktop/background" = {
|
||||||
picture-uri = "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/Wallpaper_Dark_Wide.png";
|
picture-uri-dark = "file:///run/current-system/sw/share/backgrounds/sovran/sovran-ultrawide.png";
|
||||||
picture-options = "zoom";
|
picture-options = "zoom";
|
||||||
primary-color = "#000000";
|
primary-color = "#000000";
|
||||||
secondary-color = "#000000";
|
secondary-color = "#000000";
|
||||||
@@ -47,6 +218,25 @@ in
|
|||||||
icon-theme = "Papirus-Dark";
|
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" = {
|
"org/gnome/evolution-data-server" = {
|
||||||
migrated = true;
|
migrated = true;
|
||||||
};
|
};
|
||||||
@@ -82,13 +272,12 @@ in
|
|||||||
"brave-browser.desktop"
|
"brave-browser.desktop"
|
||||||
"org.gnome.Settings.desktop"
|
"org.gnome.Settings.desktop"
|
||||||
"org.gnome.Nautilus.desktop"
|
"org.gnome.Nautilus.desktop"
|
||||||
"Sovran_SystemsOS_Updater.desktop"
|
|
||||||
"sovran-hub.desktop"
|
"sovran-hub.desktop"
|
||||||
"org.gnome.Software.desktop"
|
"org.gnome.Software.desktop"
|
||||||
"org.gnome.Geary.desktop"
|
"org.gnome.Geary.desktop"
|
||||||
"org.gnome.Contacts.desktop"
|
"org.gnome.Contacts.desktop"
|
||||||
"org.gnome.Calendar.desktop"
|
"org.gnome.Calendar.desktop"
|
||||||
"sparrow-desktop.desktop"
|
"sparrow.desktop"
|
||||||
"Bisq.desktop"
|
"Bisq.desktop"
|
||||||
"bisq2.desktop"
|
"bisq2.desktop"
|
||||||
];
|
];
|
||||||
@@ -96,6 +285,103 @@ in
|
|||||||
welcome-dialog-last-shown-version = "48.4";
|
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" = {
|
"org/gnome/shell/extensions/dash-to-dock" = {
|
||||||
background-color = "rgb(0,0,0)";
|
background-color = "rgb(0,0,0)";
|
||||||
background-opacity = 0.50000000000000001;
|
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 -"
|
"d /home/${userName}/.ssh 0700 ${userName} users -"
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.factory-ssh-keygen = {
|
systemd.services.ssh-passphrase-setup = {
|
||||||
description = "Generate factory SSH key for ${userName} if missing";
|
description = "Generate per-device SSH key passphrase";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
before = [ "factory-ssh-keygen.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
path = [ pkgs.openssh pkgs.coreutils ];
|
path = [ pkgs.pwgen pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
if [ ! -f "${keyPath}" ]; then
|
if [ ! -f "/var/lib/secrets/ssh-passphrase" ]; then
|
||||||
ssh-keygen -q -N "gosovransystems" -t ed25519 -f "${keyPath}"
|
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"
|
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
|
||||||
chmod 600 "${keyPath}"
|
chmod 600 "${keyPath}"
|
||||||
chmod 644 "${keyPath}.pub"
|
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
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
# (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
|
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||||
# and view a full audit log of every session event.
|
# 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 `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
# 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 0700 sovran-support sovran-support -"
|
||||||
"d /var/lib/sovran-support/.ssh 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 #######
|
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
||||||
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
|
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
systemd.services.livekit.wants = [ "livekit-key-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.after = [ "livekit-key-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.wants = [ "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-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
|
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" }] }\`
|
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" }] }\`
|
||||||
}
|
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
|
||||||
|
|
||||||
$MATRIX:8448 {
|
|
||||||
reverse_proxy http://localhost:8008
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$ELEMENT_CALLING {
|
$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
|
uri strip_prefix /livekit/jwt
|
||||||
reverse_proxy [::1]:8073 {
|
reverse_proxy [::1]:8073 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Forwarded-Server {host}
|
header_up X-Forwarded-Server {host}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
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
|
EOF
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
####### LIVEKIT RUNTIME CONFIG #######
|
####### LIVEKIT TURN SETUP (runtime cert + config) #######
|
||||||
systemd.services.livekit-runtime-config = {
|
# Replaces the old dead livekit-runtime-config.service. At runtime this:
|
||||||
description = "Generate LiveKit runtime config from domain files";
|
# * 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" ];
|
before = [ "livekit.service" ];
|
||||||
after = [ "livekit-key-setup.service" ];
|
|
||||||
requiredBy = [ "livekit.service" ];
|
requiredBy = [ "livekit.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -106,20 +125,63 @@ EOF
|
|||||||
unitConfig = {
|
unitConfig = {
|
||||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
|
||||||
mkdir -p /run/livekit
|
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:
|
turn:
|
||||||
|
enabled: true
|
||||||
domain: $MATRIX
|
domain: $MATRIX
|
||||||
cert_file: /var/lib/livekit/$MATRIX.crt
|
tls_port: 5349
|
||||||
key_file: /var/lib/livekit/$MATRIX.key
|
udp_port: 3478
|
||||||
|
cert_file: /run/credentials/livekit.service/turn-cert
|
||||||
|
key_file: /run/credentials/livekit.service/turn-key
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 640 /run/livekit/runtime-config.yaml
|
chmod 644 /run/livekit/livekit.yaml
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +192,11 @@ EOF
|
|||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
settings = {
|
settings = {
|
||||||
rtc.use_external_ip = true;
|
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;
|
room.auto_create = false;
|
||||||
turn = {
|
turn = {
|
||||||
enabled = true;
|
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 = [
|
networking.firewall.allowedUDPPortRanges = [
|
||||||
{ from = 7882; to = 7894; }
|
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||||
];
|
];
|
||||||
|
|
||||||
####### JWT SERVICE RUNTIME CONFIG #######
|
####### JWT SERVICE RUNTIME CONFIG #######
|
||||||
@@ -162,11 +246,13 @@ EOF
|
|||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
|
||||||
mkdir -p /run/lk-jwt-service
|
mkdir -p /run/lk-jwt-service
|
||||||
|
|
||||||
cat > /run/lk-jwt-service/env <<EOF
|
cat > /run/lk-jwt-service/env <<EOF
|
||||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||||
|
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 640 /run/lk-jwt-service/env
|
chmod 640 /run/lk-jwt-service/env
|
||||||
|
|||||||
@@ -12,11 +12,15 @@
|
|||||||
./core/sovran_systemsos-desktop.nix
|
./core/sovran_systemsos-desktop.nix
|
||||||
./core/sshd-localhost.nix
|
./core/sshd-localhost.nix
|
||||||
./core/sovran-hub.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) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
./Sovran_SystemsOS_File_Fixes_And_New_Services.nix
|
./credentials.nix
|
||||||
./credentials-pdf.nix
|
|
||||||
|
|
||||||
# ── Services (default ON — disable in custom.nix) ─────────
|
# ── Services (default ON — disable in custom.nix) ─────────
|
||||||
./synapse.nix
|
./synapse.nix
|
||||||
@@ -28,7 +32,6 @@
|
|||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
./bip110.nix
|
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
./mempool.nix
|
||||||
./bitcoin-core.nix
|
./bitcoin-core.nix
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
# ── Fully automated Nextcloud setup ───────────────────────
|
# ── Fully automated Nextcloud setup ───────────────────────
|
||||||
systemd.services.nextcloud-init = {
|
systemd.services.nextcloud-init = {
|
||||||
description = "Download, extract, and fully configure Nextcloud";
|
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" ];
|
wants = [ "network-online.target" ];
|
||||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -67,13 +67,13 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
path = with pkgs; [ curl unzip php pwgen coreutils ];
|
path = with pkgs; [ curl unzip php pwgen coreutils shadow util-linux ];
|
||||||
|
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="/var/lib/www/nextcloud"
|
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||||
DATA_DIR="/var/lib/www/nextcloud-data"
|
DATA_DIR="/var/lib/nextcloud"
|
||||||
DOMAIN=$(cat /var/lib/domains/nextcloud)
|
DOMAIN=$(cat /var/lib/domains/nextcloud)
|
||||||
DB_NAME="nextclouddb"
|
DB_NAME="nextclouddb"
|
||||||
DB_USER="ncusr"
|
DB_USER="ncusr"
|
||||||
@@ -81,6 +81,11 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
DB_HOST="localhost"
|
DB_HOST="localhost"
|
||||||
ADMIN_USER=$(pwgen -s 16 1)
|
ADMIN_USER=$(pwgen -s 16 1)
|
||||||
ADMIN_PASS=$(pwgen -s 24 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 "══════════════════════════════════════════════"
|
||||||
echo " Nextcloud Automated Installation"
|
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"
|
curl -L -o "$TEMP_DIR/nextcloud.zip" "https://download.nextcloud.com/server/releases/latest.zip"
|
||||||
unzip -q "$TEMP_DIR/nextcloud.zip" -d "$TEMP_DIR"
|
unzip -q "$TEMP_DIR/nextcloud.zip" -d "$TEMP_DIR"
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
cp -a "$TEMP_DIR/nextcloud/"* "$INSTALL_DIR/"
|
cp -a "$TEMP_DIR/nextcloud/." "$INSTALL_DIR/"
|
||||||
rm -rf "$TEMP_DIR"
|
rm -rf "$TEMP_DIR"
|
||||||
echo "Download complete."
|
echo "Download complete."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$DATA_DIR"
|
chown -R caddy:php "$INSTALL_DIR"
|
||||||
|
|
||||||
chown -R caddy:root "$INSTALL_DIR"
|
|
||||||
chown -R caddy:root "$DATA_DIR"
|
|
||||||
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
|
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
|
||||||
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
|
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
|
||||||
chmod -R 770 "$INSTALL_DIR/apps"
|
chmod -R 770 "$INSTALL_DIR/apps"
|
||||||
chmod -R 770 "$INSTALL_DIR/config"
|
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..."
|
echo "Waiting for PostgreSQL..."
|
||||||
for i in $(seq 1 30); do
|
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."
|
echo "Database ready."
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -117,7 +124,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "Running Nextcloud installation..."
|
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 \
|
php $INSTALL_DIR/occ maintenance:install \
|
||||||
--database 'pgsql' \
|
--database 'pgsql' \
|
||||||
--database-name '$DB_NAME' \
|
--database-name '$DB_NAME' \
|
||||||
@@ -129,19 +136,39 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
--data-dir '$DATA_DIR'
|
--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 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 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'
|
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 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.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
|
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 calendar || true
|
||||||
php $INSTALL_DIR/occ app:install contacts || true
|
php $INSTALL_DIR/occ app:install contacts || true
|
||||||
php $INSTALL_DIR/occ app:install tasks || 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 = [
|
services.cron.systemCronJobs = [
|
||||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/www 0755 caddy root -"
|
"d /var/lib/www 0755 caddy php -"
|
||||||
"d /var/lib/www/nextcloud 0750 caddy root -"
|
"d /var/lib/www/nextcloud 0750 caddy php -"
|
||||||
"d /var/lib/www/nextcloud-data 0770 caddy root -"
|
"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 ];
|
environment.systemPackages = with pkgs; [ unzip ];
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
sovran_systemsOS.domainRequirements = [
|
||||||
|
|||||||
@@ -28,39 +28,30 @@ let
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
|
|
||||||
{
|
{
|
||||||
users.users = {
|
options.sovran_systemsOS.phpPackage = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
php = {
|
default = custom-php;
|
||||||
isSystemUser = true;
|
description = "Shared PHP package with all extensions for Sovran_SystemsOS services";
|
||||||
createHome = false;
|
|
||||||
uid = 7777;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
users.users.php.group = "php";
|
config = {
|
||||||
|
users.users = {
|
||||||
users.groups.php = {};
|
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
php = {
|
||||||
|
isSystemUser = true;
|
||||||
custom-php
|
createHome = false;
|
||||||
];
|
uid = 7777;
|
||||||
|
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
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
|
# Enable the GNOME Remote Desktop service at the system level
|
||||||
services.gnome.gnome-remote-desktop.enable = true;
|
services.gnome.gnome-remote-desktop.enable = true;
|
||||||
|
|
||||||
# Open RDP port in the firewall
|
# Open RDP port in the firewall
|
||||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
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 = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/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 0750 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 0750 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/.local/share/gnome-remote-desktop 0750 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 = {
|
systemd.services.gnome-remote-desktop-setup = {
|
||||||
description = "Configure GNOME Remote Desktop RDP";
|
description = "Configure GNOME Remote Desktop RDP";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "graphical.target" ];
|
||||||
before = [ "gnome-remote-desktop.service" ];
|
before = [ "gnome-remote-desktop.service" ];
|
||||||
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
|
after = [
|
||||||
wants = [ "network-online.target" ];
|
"dbus.service"
|
||||||
|
"systemd-tmpfiles-setup.service"
|
||||||
|
"network-online.target"
|
||||||
|
"gnome-remote-desktop-configuration.service"
|
||||||
|
];
|
||||||
|
wants = [
|
||||||
|
"network-online.target"
|
||||||
|
"gnome-remote-desktop-configuration.service"
|
||||||
|
];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
TimeoutStartSec = "2min";
|
||||||
};
|
};
|
||||||
path = [
|
path = [
|
||||||
pkgs.gnome-remote-desktop
|
pkgs.coreutils
|
||||||
pkgs.polkit
|
|
||||||
pkgs.openssl
|
|
||||||
pkgs.hostname
|
|
||||||
pkgs.gawk
|
pkgs.gawk
|
||||||
|
pkgs.gnome-remote-desktop
|
||||||
|
pkgs.hostname
|
||||||
|
pkgs.openssl
|
||||||
|
pkgs.systemd
|
||||||
];
|
];
|
||||||
script = ''
|
script = ''
|
||||||
# Ensure directory structure exists
|
set -euo pipefail
|
||||||
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
|
|
||||||
|
|
||||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
|
||||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
# 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
|
NEED_REGEN=0
|
||||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||||
NEED_REGEN=1
|
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
|
NEED_REGEN=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$NEED_REGEN" = "1" ]; then
|
if [ "$NEED_REGEN" = "1" ]; then
|
||||||
mkdir -p "$TLS_DIR"
|
|
||||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||||
-sha256 -nodes -days 3650 \
|
-sha256 -nodes -days 3650 \
|
||||||
@@ -68,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
|||||||
echo "Generated new RDP TLS certificate"
|
echo "Generated new RDP TLS certificate"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Always fix ownership and permissions (handles re-enable after disable)
|
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
|
||||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||||
|
|
||||||
# Configure TLS certificate
|
if [ ! -f "$USERNAME_FILE" ]; then
|
||||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
|
||||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
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
|
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||||
PASSWORD=""
|
openssl rand -base64 16 > "$PASSWORD_FILE"
|
||||||
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
|
fi
|
||||||
PASSWORD=$(openssl rand -base64 16)
|
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
|
||||||
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
|
if [ -z "$PASSWORD" ]; then
|
||||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
|
echo "RDP password file is empty: $PASSWORD_FILE" >&2
|
||||||
else
|
exit 1
|
||||||
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
|
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
|
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
|
cat > "$CRED_FILE" <<EOF
|
||||||
========================================
|
========================================
|
||||||
GNOME Remote Desktop (RDP) Credentials
|
GNOME Remote Desktop (RDP) Credentials
|
||||||
========================================
|
========================================
|
||||||
|
|
||||||
Username: sovran
|
Username: $USERNAME
|
||||||
Password: $PASSWORD
|
Password: $PASSWORD
|
||||||
|
|
||||||
Connect from any RDP client to:
|
Connect from any RDP client to:
|
||||||
@@ -109,13 +170,24 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
|||||||
========================================
|
========================================
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
|
||||||
chmod 600 "$CRED_FILE"
|
chmod 600 "$CRED_FILE"
|
||||||
|
|
||||||
# Enable RDP backend and set credentials
|
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
|
||||||
grdctl --system rdp enable
|
# before any grdctl --system call. Absence means the system was booted
|
||||||
grdctl --system rdp set-credentials sovran "$PASSWORD"
|
# 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"
|
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"
|
"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"
|
"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;
|
presence.enabled = true;
|
||||||
enable_registration = false;
|
enable_registration = false;
|
||||||
listeners = [
|
listeners = [
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
|
|
||||||
cat > "$CONFIG_FILE" << 'EOF'
|
cat > "$CONFIG_FILE" << 'EOF'
|
||||||
{
|
{
|
||||||
|
"mode": "ONLINE",
|
||||||
"serverType": "ELECTRUM_SERVER",
|
"serverType": "ELECTRUM_SERVER",
|
||||||
"electrumServer": "tcp://127.0.0.1:50001",
|
"electrumServer": "tcp://127.0.0.1:50001",
|
||||||
"useProxy": false
|
"useProxy": false
|
||||||
@@ -42,42 +43,45 @@ EOF
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Bisq 1 Auto-Connect ─────────────────────────────────────
|
# ── Zeus Connect (lndconnect URL for mobile wallet) ──────────
|
||||||
systemd.services.bisq-autoconnect = {
|
systemd.services.zeus-connect-setup = {
|
||||||
description = "Auto-configure Bisq to use local Bitcoin node";
|
description = "Save Zeus lndconnect URL";
|
||||||
after = [ "bitcoind.service" ];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "lnd.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils pkgs.iproute2 ];
|
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
||||||
script = ''
|
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
|
URL=""
|
||||||
echo "Bisq config already exists, skipping"
|
if command -v lndconnect >/dev/null 2>&1; then
|
||||||
exit 0
|
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
|
fi
|
||||||
|
|
||||||
# Wait for bitcoind RPC to be ready (up to 30 attempts)
|
if [ -n "$URL" ]; then
|
||||||
ATTEMPTS=0
|
echo "$URL" > "$SECRET_FILE"
|
||||||
until ss -ltn 2>/dev/null | grep -q ':8333' || [ "$ATTEMPTS" -ge 30 ]; do
|
chmod 600 "$SECRET_FILE"
|
||||||
ATTEMPTS=$((ATTEMPTS + 1))
|
echo "Zeus connect URL saved."
|
||||||
sleep 2
|
else
|
||||||
done
|
echo "No lndconnect URL available yet."
|
||||||
|
fi
|
||||||
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"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── 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 ───────────────────────
|
# ── Fully automated WordPress setup ───────────────────────
|
||||||
systemd.services.wordpress-init = {
|
systemd.services.wordpress-init = {
|
||||||
description = "Download, extract, and fully configure WordPress";
|
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" ];
|
wants = [ "network-online.target" ];
|
||||||
requires = [ "mysql.service" "wordpress-db-init.service" ];
|
requires = [ "mysql.service" "wordpress-db-init.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -60,7 +60,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
|||||||
RemainAfterExit = true;
|
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 = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -73,7 +73,11 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
|||||||
DB_HOST="localhost"
|
DB_HOST="localhost"
|
||||||
ADMIN_USER=$(pwgen -s 16 1)
|
ADMIN_USER=$(pwgen -s 16 1)
|
||||||
ADMIN_PASS=$(pwgen -s 24 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 "══════════════════════════════════════════════"
|
||||||
echo " WordPress Automated Installation"
|
echo " WordPress Automated Installation"
|
||||||
@@ -90,14 +94,14 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
|||||||
echo "Download complete."
|
echo "Download complete."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chown -R caddy:root "$INSTALL_DIR"
|
chown -R caddy:php "$INSTALL_DIR"
|
||||||
find "$INSTALL_DIR" -type d -exec chmod 755 {} \;
|
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
|
||||||
find "$INSTALL_DIR" -type f -exec chmod 644 {} \;
|
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
|
||||||
chmod -R 775 "$INSTALL_DIR/wp-content"
|
chmod -R 770 "$INSTALL_DIR/wp-content"
|
||||||
|
|
||||||
echo "Generating wp-config.php..."
|
echo "Generating wp-config.php..."
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
su -s /bin/sh caddy -c "
|
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||||
wp config create \
|
wp config create \
|
||||||
--dbname='$DB_NAME' \
|
--dbname='$DB_NAME' \
|
||||||
--dbuser='$DB_USER' \
|
--dbuser='$DB_USER' \
|
||||||
@@ -108,14 +112,14 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
|||||||
|
|
||||||
echo "Waiting for database..."
|
echo "Waiting for database..."
|
||||||
for i in $(seq 1 30); do
|
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
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Running WordPress core install..."
|
echo "Running WordPress core install..."
|
||||||
su -s /bin/sh caddy -c "
|
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||||
wp core install \
|
wp core install \
|
||||||
--url='https://$DOMAIN' \
|
--url='https://$DOMAIN' \
|
||||||
--title='Sovran_SystemsOS' \
|
--title='Sovran_SystemsOS' \
|
||||||
@@ -125,7 +129,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
|||||||
--skip-email
|
--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 blogdescription 'Powered by Sovran_SystemsOS'
|
||||||
wp option update permalink_structure '/%postname%/'
|
wp option update permalink_structure '/%postname%/'
|
||||||
wp option update default_ping_status 'closed'
|
wp option update default_ping_status 'closed'
|
||||||
@@ -133,7 +137,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
|||||||
wp rewrite flush
|
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 DISALLOW_FILE_EDIT true --raw
|
||||||
wp config set WP_AUTO_UPDATE_CORE true --raw
|
wp config set WP_AUTO_UPDATE_CORE true --raw
|
||||||
wp config set FORCE_SSL_ADMIN 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 = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/www 0755 caddy root -"
|
"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 ];
|
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>
|
|
||||||