Compare commits
650
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574057811f | ||
|
|
e0cc3988fc | ||
|
|
44d9036440 | ||
|
|
1c6d9e8495 | ||
|
|
f9dec9b230 | ||
|
|
872699c38c | ||
|
|
eb32d367c8 | ||
|
|
a134f8443a | ||
|
|
ee1f9f3b32 | ||
|
|
5326639918 | ||
|
|
3bf80a117b | ||
|
|
189a8fa963 | ||
|
|
7b13944c24 | ||
|
|
e7384d334a | ||
|
|
be299845ca | ||
|
|
d83061786c | ||
|
|
b28a2263bb | ||
|
|
b6f45390c4 | ||
|
|
2e4bc687d4 | ||
|
|
d328d3aaca | ||
|
|
89cc524f3f | ||
|
|
f0f2600bed | ||
|
|
884fd189fb | ||
|
|
e08cc2f928 | ||
|
|
3ce963b0cd | ||
|
|
461a79277d | ||
|
|
d9f7fa6993 | ||
|
|
060fa64339 | ||
|
|
30d83c4c32 | ||
|
|
a918b1bdc1 | ||
|
|
66b1a041ba | ||
|
|
d55ed2b19a | ||
|
|
8135c8d781 | ||
|
|
577b060b8a | ||
|
|
1028a2e43a | ||
|
|
eba93a812e | ||
|
|
83b7256b60 | ||
|
|
9d17f539b5 | ||
|
|
28f746c7e2 | ||
|
|
9d218cb19f | ||
|
|
f9ef9c4929 | ||
|
|
049e005873 | ||
|
|
c440b59b93 | ||
|
|
3e4865884c | ||
|
|
270d55e6a6 | ||
|
|
7a0a1953f6 | ||
|
|
3e4ccc9720 | ||
|
|
e5947489e4 | ||
|
|
0a7a10aeed | ||
|
|
28b813ba64 | ||
|
|
68160dc681 | ||
|
|
32e7420d89 | ||
|
|
7e1d67dba4 | ||
|
|
da1dc90ac5 | ||
|
|
72c9492223 | ||
|
|
fa67d839cd | ||
|
|
1452928b75 | ||
|
|
bf10023f35 | ||
|
|
f4f41e400b | ||
|
|
3abbdc41d6 | ||
|
|
6ba54f690c | ||
|
|
a3c6b2a305 | ||
|
|
21a2d077d8 | ||
|
|
6263c7e16f | ||
|
|
161835802d | ||
|
|
6f998ff506 | ||
|
|
d192589790 | ||
|
|
21c2bb2646 | ||
|
|
9c0bbd13dd | ||
|
|
1c15961309 | ||
|
|
383b763a66 | ||
|
|
3e99a9df33 | ||
|
|
f1b6f90345 | ||
|
|
2a660697c5 | ||
|
|
0c08dda635 | ||
|
|
22b99ff895 | ||
|
|
2fab784ba7 | ||
|
|
83cdf92575 | ||
|
|
aa1c8e4aa1 | ||
|
|
ac61015cc0 | ||
|
|
a0fbf5b9ba | ||
|
|
ddf0814803 | ||
|
|
2bc15648b7 | ||
|
|
7f348b7b2b | ||
|
|
a71fd9a9f4 | ||
|
|
36848a519a | ||
|
|
ea6d0b969c | ||
|
|
4ba9983cf8 | ||
|
|
8caaa4540f | ||
|
|
42f0cb67cb | ||
|
|
1dfb3cc28c | ||
|
|
b51e87477e | ||
|
|
05c0ad43d2 | ||
|
|
4a07af049c | ||
|
|
057c193e26 | ||
|
|
38a731f269 | ||
|
|
06d69590fc | ||
|
|
6adee810dc | ||
|
|
00d4307346 | ||
|
|
7e767ecb4f | ||
|
|
18495dba68 | ||
|
|
689d0e1d5b | ||
|
|
95527b3956 | ||
|
|
225b53bfa7 | ||
|
|
965419b2b8 | ||
|
|
f6988b0f1e | ||
|
|
0784ef3719 | ||
|
|
9df4a29210 | ||
|
|
4c88d6e768 | ||
|
|
a85a354e57 | ||
|
|
9b18d09d9b | ||
|
|
a398da0eac | ||
|
|
edb8406e05 | ||
|
|
bfd185adbb | ||
|
|
182752d9ab | ||
|
|
3b4c87a292 | ||
|
|
2685e9ad06 | ||
|
|
4de67e4bea | ||
|
|
524ccc6412 | ||
|
|
be4f488db3 | ||
|
|
2f60b81962 | ||
|
|
92692de94d | ||
|
|
a5f9fca59e | ||
|
|
72e5228351 | ||
|
|
33b5ec0788 | ||
|
|
1b718e7c59 | ||
|
|
6ad65a1242 | ||
|
|
a41f2b26cc | ||
|
|
71f9a9dca5 | ||
|
|
449684ceaa | ||
|
|
cdc50b7aaf | ||
|
|
f534b74066 | ||
|
|
3c15ee15ef | ||
|
|
45a4f968d6 | ||
|
|
df09e42cf2 | ||
|
|
ee427ed6e1 | ||
|
|
5856deede3 | ||
|
|
e02b263054 | ||
|
|
1d6c89c368 | ||
|
|
0edfddb710 | ||
|
|
71a4f53bed | ||
|
|
de7350fce9 | ||
|
|
21786fa1a8 | ||
|
|
84f9587b9a | ||
|
|
a20e165ac2 | ||
|
|
ed260cd86c | ||
|
|
99cd2a8ec3 | ||
|
|
a228ab24a0 | ||
|
|
3acbf01d46 | ||
|
|
675689a458 | ||
|
|
42f3f3e640 | ||
|
|
21238fe707 | ||
|
|
ef86ef04a1 | ||
|
|
727bb09eff | ||
|
|
1fe4ba5999 | ||
|
|
e434beec7a | ||
|
|
fe1dfe472a | ||
|
|
0cfaf6670c | ||
|
|
4d67341ba5 | ||
|
|
1fa9160c59 | ||
|
|
d559cccd44 | ||
|
|
0684d84609 | ||
|
|
78f1bf853c | ||
|
|
e28238191d | ||
|
|
82bcc5776f | ||
|
|
1993802c38 | ||
|
|
5db49b6b0e | ||
|
|
0c15b25ecd | ||
|
|
0c21765da3 | ||
|
|
4ff8fc8d51 | ||
|
|
483053b9a2 | ||
|
|
fd4c51f3db | ||
|
|
1b351cfca4 | ||
|
|
cf9d85b3cd | ||
|
|
6a4ef5b6c6 | ||
|
|
501cf4e733 | ||
|
|
89c21d752a | ||
|
|
0e38d9d500 | ||
|
|
3511c34daa | ||
|
|
0838d1d735 | ||
|
|
d1769fc886 | ||
|
|
6dced22499 | ||
|
|
5cee53dc5f | ||
|
|
81248bb159 | ||
|
|
6354d54de8 | ||
|
|
da6d64f95c | ||
|
|
9ba3d4a61f | ||
|
|
eee236a072 | ||
|
|
9df89e2db4 | ||
|
|
f60c509b47 | ||
|
|
84dfcfeac7 | ||
|
|
5dda3b5c4a | ||
|
|
db64320bd8 | ||
|
|
583f60771c | ||
|
|
a92c3190c2 | ||
|
|
3a6d24fe0e | ||
|
|
c277ecff44 | ||
|
|
bd690c94c3 | ||
|
|
a22fdf197e | ||
|
|
bd24b03cac | ||
|
|
3afc4ab012 | ||
|
|
d1ac3e98ce | ||
|
|
5bba54f3e5 | ||
|
|
fe7bc300e2 | ||
|
|
00c03c365d | ||
|
|
dc8dd3dd58 | ||
|
|
85a8865892 | ||
|
|
50a9ac5fdc | ||
|
|
3388d2f895 | ||
|
|
3a77fc2abd | ||
|
|
3d59836d0c | ||
|
|
d9184312aa | ||
|
|
b9802e6b04 | ||
|
|
c2635ed51a | ||
|
|
b21ac05547 | ||
|
|
484b620867 | ||
|
|
439bc2ed7d | ||
|
|
a1e6986a64 | ||
|
|
d0e1cc4ad6 | ||
|
|
b877024365 | ||
|
|
2de7ac116b | ||
|
|
fa1fd14ed1 | ||
|
|
d1b3cd2f74 | ||
|
|
e00a0da5d9 | ||
|
|
fef0b7c7a1 | ||
|
|
efd29dc259 | ||
|
|
13cd41d202 | ||
|
|
3530ce6cb7 | ||
|
|
09522c2566 | ||
|
|
80f0afb28b | ||
|
|
287bd9657b | ||
|
|
b5f684c4fe | ||
|
|
1f08e90009 | ||
|
|
6881d92d0a | ||
|
|
5e016c6584 | ||
|
|
537b8758ff | ||
|
|
c03360333b | ||
|
|
fa7c5d341d | ||
|
|
b6fc8c3f77 | ||
|
|
37f2c1457e | ||
|
|
3a626922a5 | ||
|
|
dde47de145 | ||
|
|
f3b9f6f286 | ||
|
|
f1c3f67864 | ||
|
|
8f5873afca | ||
|
|
e22faebfcd | ||
|
|
e2b01b62a5 | ||
|
|
0858693d57 | ||
|
|
45f7c0c393 | ||
|
|
c56bfb7270 | ||
|
|
eb45072031 | ||
|
|
1e2132c1a1 | ||
|
|
19ef773690 | ||
|
|
c5aae0614a | ||
|
|
17d97aaf52 | ||
|
|
1fb9bd827f | ||
|
|
8699dc5b7e | ||
|
|
71240f183c | ||
|
|
01e8b0ba44 | ||
|
|
2aa4784518 | ||
|
|
f611cae438 | ||
|
|
1eb98ef962 | ||
|
|
6f86496f10 | ||
|
|
57a9b18102 | ||
|
|
36995fa62b | ||
|
|
9121fc461f | ||
|
|
fc56bae5f9 | ||
|
|
ac75b3ef76 | ||
|
|
e6fe463216 | ||
|
|
8528f14ed7 | ||
|
|
df1d9658f5 | ||
|
|
9f9b384481 | ||
|
|
165114471f | ||
|
|
de78688093 | ||
|
|
bbf9f72fd3 | ||
|
|
978b665aa6 | ||
|
|
1fe608f531 | ||
|
|
1e1546cb60 | ||
|
|
119d8694d1 | ||
|
|
8d43c689f5 | ||
|
|
05f10ed3c9 | ||
|
|
c0bec3737b | ||
|
|
59d147fe4d | ||
|
|
9e38a01e3d | ||
|
|
20a302f84a | ||
|
|
ba2e263d00 | ||
|
|
a000703199 | ||
|
|
8fcda63742 | ||
|
|
3363ac9dad | ||
|
|
a7e338b171 | ||
|
|
bc79daab48 | ||
|
|
d3d8dba3ff | ||
|
|
6d047e25ab | ||
|
|
ed4c39650c | ||
|
|
7b8fa4a8a0 | ||
|
|
8a02c35ec9 | ||
|
|
487de34a50 | ||
|
|
0424547dd4 | ||
|
|
5be9ddb2e5 | ||
|
|
bc6c7cdb5a | ||
|
|
f46fb7fc0e | ||
|
|
d9945882e5 | ||
|
|
9db16522e3 | ||
|
|
6070972f9a | ||
|
|
31e0000306 | ||
|
|
26567766a9 | ||
|
|
97dc6feaca | ||
|
|
f10e61cde4 | ||
|
|
febe48a974 | ||
|
|
766dcabfde | ||
|
|
c9dab99271 | ||
|
|
60a4ed9aab | ||
|
|
f56fc2e54d | ||
|
|
dabe6fe3aa | ||
|
|
a232c74990 | ||
|
|
5e326335af | ||
|
|
14a11886ae | ||
|
|
4be7d24aec | ||
|
|
dd4ce5bb3e | ||
|
|
5ee3f14eed | ||
|
|
5dcc1bf1be | ||
|
|
facef270b7 | ||
|
|
e8c75e974d | ||
|
|
60af88525c | ||
|
|
74f6dca2f5 | ||
|
|
effd991c31 | ||
|
|
20e57d19c7 | ||
|
|
d5c8d0d0f2 | ||
|
|
2869e63d0a | ||
|
|
792b7eb211 | ||
|
|
8fd5eacec6 | ||
|
|
66140aaf58 | ||
|
|
fdfe8e8e46 | ||
|
|
4ad68e3ac4 | ||
|
|
0a86167c44 | ||
|
|
3537ec59dc | ||
|
|
8bbecd2035 | ||
|
|
c10f093cad | ||
|
|
28b138b4c3 | ||
|
|
01bb37125d | ||
|
|
2e8114c41e | ||
|
|
6832bfd7bb | ||
|
|
fbd93d0ea5 | ||
|
|
3d3be7465f | ||
|
|
c4e6ad5485 | ||
|
|
3fc726da9e | ||
|
|
73b6b548f0 | ||
|
|
ff0caf5a90 | ||
|
|
87dc9fc858 | ||
|
|
dd306e4757 | ||
|
|
fa75d70475 | ||
|
|
653bd5a755 | ||
|
|
01eda1dbb0 | ||
|
|
11ebd324ad | ||
|
|
3a6c5ebae8 | ||
|
|
34a0373eca | ||
|
|
6bf288f83e | ||
|
|
7b077905e2 | ||
|
|
2d12669f9b | ||
|
|
a05a74cf4d | ||
|
|
14b855fa9f | ||
|
|
0f5ad1d836 | ||
|
|
cca0ffbeae | ||
|
|
0c663945ee | ||
|
|
b37ed967b6 | ||
|
|
a7b9af4422 | ||
|
|
310b3ab03f | ||
|
|
fbad3e44e5 | ||
|
|
e4a3f22871 | ||
|
|
e8cc9b4ecd | ||
|
|
0b1efca737 | ||
|
|
aef5811c16 | ||
|
|
b209fed4a7 | ||
|
|
104f21d0a9 | ||
|
|
b86d9ddd86 | ||
|
|
9cf04a6940 | ||
|
|
cb90ed12fb | ||
|
|
a940390791 | ||
|
|
4e90e8619f | ||
|
|
372a8c5ddf | ||
|
|
909ddb884e | ||
|
|
1836237f82 | ||
|
|
983655d2a1 | ||
|
|
78a0a610be | ||
|
|
5f35b57268 | ||
|
|
cf318470b8 | ||
|
|
d703bbc4e8 | ||
|
|
17bcf4b5b9 | ||
|
|
da3afca7fa | ||
|
|
88f49a96ae | ||
|
|
d15ab78bd5 | ||
|
|
8eb14c1502 | ||
|
|
f8cd909828 | ||
|
|
6a3e0a29a4 | ||
|
|
c19c11e6eb | ||
|
|
85f6d47024 | ||
|
|
4175608772 | ||
|
|
24060b2c5a | ||
|
|
cefbac625c | ||
|
|
73efb206ac | ||
|
|
7fea321376 | ||
|
|
b583e9803f | ||
|
|
92ac1eeb62 | ||
|
|
242a587340 | ||
|
|
7a8e683d99 | ||
|
|
3e447fd024 | ||
|
|
6953f5e972 | ||
|
|
55526263a0 | ||
|
|
4bb7400b8e | ||
|
|
79afcc2e16 | ||
|
|
8708bd9498 | ||
|
|
58c37bf81b | ||
|
|
946a748038 | ||
|
|
481649e03f | ||
|
|
cfdc00552e | ||
|
|
b7221d8111 | ||
|
|
7e6d7074d6 | ||
|
|
268134d821 | ||
|
|
c829cc41d9 | ||
|
|
07a3756b18 | ||
|
|
769839a70d | ||
|
|
480a578deb | ||
|
|
a636a48e07 | ||
|
|
427191b14c | ||
|
|
b8fcf89ee7 | ||
|
|
64eac6dbc7 | ||
|
|
c4f1684304 | ||
|
|
8ed3bec511 | ||
|
|
58faf2e57f | ||
|
|
8e5f35f40c | ||
|
|
eb8b85ccfe | ||
|
|
09a39090c6 | ||
|
|
8626898e5e | ||
|
|
ee4dff09c9 | ||
|
|
8d88b16f20 | ||
|
|
120c9c6735 | ||
|
|
855537c535 | ||
|
|
1d3fcebb28 | ||
|
|
f9f382049d | ||
|
|
e4d7569a1c | ||
|
|
c3e363eccc | ||
|
|
6fde319b2f | ||
|
|
4f1fce32c1 | ||
|
|
968408b955 | ||
|
|
95c5d531ae | ||
|
|
b0d8edf9b6 | ||
|
|
a50219c0d9 | ||
|
|
f10fe61916 | ||
|
|
bc1cda26f8 | ||
|
|
f646ce5c47 | ||
|
|
1eb8c79623 | ||
|
|
539403cccf | ||
|
|
50a06dfdc0 | ||
|
|
b70ccc97d4 | ||
|
|
3f0f12b111 | ||
|
|
3891a2c239 | ||
|
|
4f041d2f4b | ||
|
|
43a2fdb3a0 | ||
|
|
de0e7b7cae | ||
|
|
0226936758 | ||
|
|
42cd73c5b8 | ||
|
|
51622a922b | ||
|
|
76d3111a72 | ||
|
|
b335dc77e3 | ||
|
|
51db5ab2e9 | ||
|
|
d715d2150c | ||
|
|
98687043f7 | ||
|
|
bdc26e4799 | ||
|
|
b027ad3f7f | ||
|
|
fd32c96d86 | ||
|
|
b74cd10dfc | ||
|
|
460b4afb22 | ||
|
|
9cc9e68d90 | ||
|
|
accf7493e9 | ||
|
|
f0547317f7 | ||
|
|
60f0ddecd6 | ||
|
|
8f5aebdd63 | ||
|
|
a6030ef62f | ||
|
|
7df79d05f1 | ||
|
|
f002eab1f6 | ||
|
|
e798365be2 | ||
|
|
1a6cf03c03 | ||
|
|
3b52bcbeb8 | ||
|
|
693d59a3e2 | ||
|
|
7a3b8cb700 | ||
|
|
97bc766afb | ||
|
|
9ffae221ac | ||
|
|
cbb66c63f6 | ||
|
|
0d4fb896bb | ||
|
|
0b7e55d301 | ||
|
|
77a92787fb | ||
|
|
aa31cd8a10 | ||
|
|
e70b2f0e67 | ||
|
|
156c5354de | ||
|
|
e2f5f1fa8c | ||
|
|
4f512d01f1 | ||
|
|
e5363a64ee | ||
|
|
5a701acc82 | ||
|
|
dff3668a25 | ||
|
|
850aa0ed05 | ||
|
|
d0ed9885e7 | ||
|
|
ff22340561 | ||
|
|
2038e86b53 | ||
|
|
404214d82e | ||
|
|
01fd2e201e | ||
|
|
066095ffca | ||
|
|
022b1ef8ec | ||
|
|
a8771a6e4d | ||
|
|
502045d3af | ||
|
|
ea73fc3a18 | ||
|
|
75b86e3843 | ||
|
|
9767e18123 | ||
|
|
19b4aef95b | ||
|
|
0464c540b2 | ||
|
|
45178d455e | ||
|
|
f28ab1a741 | ||
|
|
df6f8b6f62 | ||
|
|
8f3a27100f | ||
|
|
69c7a352f6 | ||
|
|
a2bfa98a2d | ||
|
|
57151826ae | ||
|
|
e413009faa | ||
|
|
3ec9f1b35f | ||
|
|
e019493087 | ||
|
|
ca2c05db14 | ||
|
|
1850f352a2 | ||
|
|
850ffbafe1 | ||
|
|
a28157dcf8 | ||
|
|
03e2c3c50d | ||
|
|
fb1a1292ec | ||
|
|
ec201a23a2 | ||
|
|
d9a33b0672 | ||
|
|
2b7ef98dff | ||
|
|
b5f30bc7c8 | ||
|
|
3a0116248e | ||
|
|
6af0a88841 | ||
|
|
e4c3fc24d3 | ||
|
|
e46d0edbf2 | ||
|
|
a4c4a72dbc | ||
|
|
67d729b360 | ||
|
|
da6d825f45 | ||
|
|
93423e32e6 | ||
|
|
d0442291f5 | ||
|
|
6c5472760b | ||
|
|
7c4a676742 | ||
|
|
fbda26a188 | ||
|
|
90ce7af769 | ||
|
|
b543cd1b3d | ||
|
|
15c9da1b01 | ||
|
|
baa7bb239d | ||
|
|
7342c46d99 | ||
|
|
813f9e6fef | ||
|
|
434f14ae3a | ||
|
|
8398fd2279 | ||
|
|
56f06b9eaf | ||
|
|
d9d241f83b | ||
|
|
aee910c1f8 | ||
|
|
bea545e873 | ||
|
|
82d7dde5f8 | ||
|
|
397016ad68 | ||
|
|
39348c9491 | ||
|
|
63dadf6239 | ||
|
|
d905c99d32 | ||
|
|
85e1baf59a | ||
|
|
351ad59dd8 | ||
|
|
dcc901b0d2 | ||
|
|
99bf093f00 | ||
|
|
619ccd28cb | ||
|
|
f22f0a4729 | ||
|
|
78194daf5f | ||
|
|
f141767fc2 | ||
|
|
05cd8e154b | ||
|
|
004cc03ba6 | ||
|
|
236e89989f | ||
|
|
b0a2de8ca1 | ||
|
|
e9ac7be8c3 | ||
|
|
47690c58d9 | ||
|
|
5d72088837 | ||
|
|
7a60295bc1 | ||
|
|
b48467fb6e | ||
|
|
b5e828c9e8 | ||
|
|
98284f4387 | ||
|
|
e35e8fc839 | ||
|
|
2cd9bc1c89 | ||
|
|
39980581b1 | ||
|
|
6f478eb817 | ||
|
|
ff3a94b888 | ||
|
|
631894084a | ||
|
|
296e0179cb | ||
|
|
600126a913 | ||
|
|
4872a26786 | ||
|
|
f0c86a3bdf | ||
|
|
1b286762f6 | ||
|
|
3c77c20de8 | ||
|
|
9e53f21746 | ||
|
|
ad35b32f5b | ||
|
|
d20d3b08fa | ||
|
|
c3c58581cc | ||
|
|
c558b81471 | ||
|
|
963fa9c877 | ||
|
|
a02747d02e | ||
|
|
db5b5e173f | ||
|
|
2657780e7a | ||
|
|
129be23a9d | ||
|
|
d31486ae1b | ||
|
|
4f3f3601d2 | ||
|
|
b022722d39 | ||
|
|
bf96dba50e | ||
|
|
2b4611f7ac | ||
|
|
fdbd591c73 | ||
|
|
1989d6cd98 | ||
|
|
763eafa4f8 | ||
|
|
3dff45350b | ||
|
|
40dee57688 | ||
|
|
b7561ed4e5 | ||
|
|
15fdf591e0 | ||
|
|
e09a61c0af | ||
|
|
55bae898f3 | ||
|
|
a9d602d021 | ||
|
|
2fe08ad7e9 | ||
|
|
1ad5b5d6db | ||
|
|
50907448d2 | ||
|
|
0962745bbc | ||
|
|
38c51e5a3e | ||
|
|
c26f120e42 | ||
|
|
d206bb0541 | ||
|
|
42d1ec99a8 | ||
|
|
91d33918bb | ||
|
|
efa5b36389 | ||
|
|
332c7760ca | ||
|
|
138f708a87 | ||
|
|
9ec3cbf901 | ||
|
|
86ce1b3ff7 | ||
|
|
307946d5aa | ||
|
|
1967e966ce | ||
|
|
19b76044ff | ||
|
|
257e4fa89d | ||
|
|
f06009b152 | ||
|
|
aeee7aeccf | ||
|
|
d69ab709b2 | ||
|
|
d7c90dea07 | ||
|
|
4edb3bf441 | ||
|
|
73a06227ba | ||
|
|
7c30d26878 | ||
|
|
19596ff2a3 | ||
|
|
c3c16083f7 | ||
|
|
e37a09ef0d | ||
|
|
02e84ed548 | ||
|
|
7e66b23ef8 | ||
|
|
5c91db0d4c |
@@ -0,0 +1 @@
|
||||
graphify-out/graph.json merge=graphify
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Agent Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "agent/v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-docker
|
||||
container: node:26
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: agent/go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build
|
||||
working-directory: agent
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
GOOS=linux GOARCH=arm64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-arm64 ./cmd
|
||||
|
||||
- name: Checksums
|
||||
working-directory: agent/dist
|
||||
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 > checksums.txt
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: |
|
||||
agent/dist/vantage-agent-linux-amd64
|
||||
agent/dist/vantage-agent-linux-arm64
|
||||
agent/dist/checksums.txt
|
||||
@@ -0,0 +1,239 @@
|
||||
name: Chart Release
|
||||
|
||||
on:
|
||||
# Every push that touches the chart is validated. Publishing is separate and
|
||||
# deliberate: a chart version is immutable in the registry once pushed, so
|
||||
# it must come from a tag someone chose, not from whatever landed on main.
|
||||
# No `paths` filter on push, deliberately. A paths filter applies to tag
|
||||
# pushes too, so tagging a commit that happened not to touch the chart
|
||||
# would skip the publish entirely — a release that silently does nothing.
|
||||
# Validation is seconds of helm rendering; running it on every push to main
|
||||
# is cheaper than that failure mode.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "chart/v*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/chart/**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CHART_DIR: deploy/chart/vantage
|
||||
HELM_VERSION: v3.16.3
|
||||
|
||||
jobs:
|
||||
chart:
|
||||
runs-on: ubuntu-docker
|
||||
container: alpine:3.21
|
||||
steps:
|
||||
# git for actions/checkout, curl for both the Helm download and the
|
||||
# registry upload, tar because the Helm tarball is not self-extracting.
|
||||
- name: Setup
|
||||
run: apk add --no-cache bash curl git tar nodejs npm
|
||||
|
||||
- name: Install Helm
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
|
||||
| tar -xz -C /tmp linux-amd64/helm
|
||||
mv /tmp/linux-amd64/helm /usr/local/bin/helm
|
||||
helm version --short
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint
|
||||
run: helm lint "$CHART_DIR"
|
||||
|
||||
# Rendering is the real test. `helm lint` accepts a chart whose
|
||||
# templates fail to execute, and every guard in this chart is a
|
||||
# template `fail` that only fires during rendering.
|
||||
- name: Render default values
|
||||
run: helm template test "$CHART_DIR" > /dev/null
|
||||
|
||||
- name: Render a multi-replica install
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set server.replicaCount=3 \
|
||||
--set web.replicaCount=3 > /dev/null
|
||||
|
||||
# The reaper deletes whole instances, so "does this env appear only
|
||||
# in cloud mode" is worth asserting rather than eyeballing.
|
||||
- name: Check the reaper is cloud-only
|
||||
run: |
|
||||
set -eu
|
||||
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
|
||||
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
|
||||
exit 1
|
||||
fi
|
||||
if ! helm template test "$CHART_DIR" \
|
||||
--set server.env.deploymentType=cloud \
|
||||
| grep -q FREE_INSTANCE_REAP_AFTER; then
|
||||
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: reaper configured in cloud mode only"
|
||||
|
||||
- name: Render with backups enabled
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.image=gitea.hostxtra.co.uk/vantage/vantage-ctl:latest \
|
||||
--set backup.pvcName=vantage-backups > /dev/null
|
||||
|
||||
- name: Render against external Redis and MongoDB
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set redis.enabled=false \
|
||||
--set redis.addr=redis.example.com:6379 \
|
||||
--set mongo.enabled=false \
|
||||
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
|
||||
|
||||
- name: Render with the Traefik ingress
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.host=agents.example.com \
|
||||
--set ingress.tls.certResolver=letsencrypt \
|
||||
--set server.env.grpcHost=agents.example.com:443 > /dev/null
|
||||
|
||||
# The shape the cloud deployment actually uses: a wildcard tenant
|
||||
# namespace, /api and /auth routed at the edge, and no apex — that
|
||||
# belongs to the marketing site, which this chart does not deploy.
|
||||
- name: Render a wildcard host with edge-routed API paths
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set 'ingress.web.host=*.vantage.example.com' \
|
||||
--set ingress.api.enabled=true \
|
||||
--set ingress.grpc.host=agents.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443 \
|
||||
--set ingress.tls.secretName=vantage-tls \
|
||||
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
|
||||
|
||||
# The guards are load-bearing, so their absence is a regression the
|
||||
# same way a broken render is. Each of these must fail.
|
||||
- name: Check the guards still refuse bad values
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
refuses() {
|
||||
desc="$1"; shift
|
||||
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
|
||||
echo "GUARD MISSING: $desc was accepted"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: refused $desc"
|
||||
}
|
||||
|
||||
refuses "mongo disabled with an in-chart URI" \
|
||||
--set mongo.enabled=false
|
||||
refuses "redis disabled with no external address" \
|
||||
--set redis.enabled=false
|
||||
refuses "multiple replicas on a ReadWriteOnce volume" \
|
||||
--set server.replicaCount=2 --set server.persistence.enabled=true
|
||||
refuses "ingress with no web host" \
|
||||
--set ingress.enabled=true
|
||||
refuses "edge-routed API with an empty path list" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.enabled=false \
|
||||
--set ingress.api.enabled=true \
|
||||
--set 'ingress.api.paths=null'
|
||||
refuses "gRPC ingress with no host" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443
|
||||
refuses "an ingress that leaves /api unrouted" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.enabled=false \
|
||||
--set ingress.api.enabled=false
|
||||
refuses "gRPC ingress while grpcHost is still in-cluster" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.host=agents.example.com
|
||||
refuses "backup enabled with no pvcName" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.image=gitea.hostxtra.co.uk/vantage/vantage-ctl:latest
|
||||
refuses "backup enabled with no image" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.pvcName=vantage-backups
|
||||
|
||||
- name: Read the chart version
|
||||
id: chart
|
||||
run: |
|
||||
set -eu
|
||||
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "chart version is $VERSION"
|
||||
|
||||
# Chart.yaml is the source of truth for the version; the tag only
|
||||
# says "publish this one". A mismatch is a mistake worth stopping
|
||||
# for — the alternative is stamping the tag over Chart.yaml, which
|
||||
# leaves the repository disagreeing with what was published.
|
||||
- name: Check the tag matches Chart.yaml
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
run: |
|
||||
set -eu
|
||||
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
|
||||
CHART_VERSION="${{ steps.chart.outputs.version }}"
|
||||
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
|
||||
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
|
||||
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
helm package "$CHART_DIR" --destination dist
|
||||
ls -l dist
|
||||
|
||||
- name: Publish to the Gitea chart registry
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
env:
|
||||
# github.server_url is this Gitea instance, so the registry
|
||||
# host needs no variable of its own and cannot drift from it.
|
||||
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
|
||||
# The same pair server-deploy.yml uses for `docker login`.
|
||||
# RELEASE_TOKEN, not REGISTRY_PASSWORD: the latter is named in
|
||||
# the docs but set by no workflow, and an unset secret becomes
|
||||
# an empty password, which Gitea reports as "Failed to
|
||||
# authenticate user" rather than as a missing credential.
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CHART_VERSION: ${{ steps.chart.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
PKG="dist/vantage-${CHART_VERSION}.tgz"
|
||||
test -f "$PKG"
|
||||
|
||||
# Checked explicitly, because the failure it prevents is a
|
||||
# 401 that looks like a permissions problem on the token that
|
||||
# was never sent.
|
||||
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
|
||||
echo "REGISTRY_USER or RELEASE_TOKEN is not set on this repository."
|
||||
echo "RELEASE_TOKEN needs the write:package scope to publish a chart."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "publishing to ${REGISTRY} as ${REGISTRY_USER}"
|
||||
|
||||
# --fail-with-body so an HTTP error is a failed step with the
|
||||
# server's explanation, rather than a green run that published
|
||||
# nothing. A repeated version is rejected by the registry;
|
||||
# that is the intended behaviour, not something to retry past.
|
||||
curl --fail-with-body -sS \
|
||||
--user "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
|
||||
-X POST \
|
||||
--upload-file "$PKG" \
|
||||
"$REGISTRY"
|
||||
|
||||
echo "published vantage ${CHART_VERSION}"
|
||||
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
|
||||
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
|
||||
@@ -4,16 +4,105 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Manual runs rebuild everything: there is no "before" commit to diff
|
||||
# against, which the change detection below treats as "build it all". That
|
||||
# is also the escape hatch for a repo VARIABLE change — editing HQ_URL
|
||||
# pushes no commit, so nothing would rebuild on its own.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-docker
|
||||
container: docker:dind
|
||||
steps:
|
||||
- name: Setup Node
|
||||
run: apk add --update nodejs npm
|
||||
# git is needed twice over: actions/checkout clones with it, and the
|
||||
# change detection below diffs with it.
|
||||
- name: Setup
|
||||
run: apk add --update nodejs npm git
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The default shallow clone has one commit, which cannot be
|
||||
# diffed against the previous push.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Work out what changed
|
||||
id: changed
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
BEFORE="${{ github.event.before }}"
|
||||
ZERO="0000000000000000000000000000000000000000"
|
||||
|
||||
# Build everything whenever the comparison cannot be trusted:
|
||||
# a manual run, a brand-new branch, or a force-push whose old
|
||||
# head is no longer in the repository. Listing every tracked
|
||||
# file makes every filter below match, so there is no second
|
||||
# code path to keep correct.
|
||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||
echo "No usable base commit — building every image."
|
||||
git ls-files > /tmp/changed.txt
|
||||
else
|
||||
git diff --name-only "$BEFORE" HEAD > /tmp/changed.txt
|
||||
fi
|
||||
|
||||
echo "--- changed files ---"
|
||||
cat /tmp/changed.txt
|
||||
echo "---------------------"
|
||||
|
||||
# A change to the workflow itself can change a build arg, and
|
||||
# a build arg is baked into the image, so it rebuilds all.
|
||||
if grep -qE '^\.gitea/workflows/' /tmp/changed.txt; then
|
||||
ALL=1
|
||||
else
|
||||
ALL=0
|
||||
fi
|
||||
|
||||
flag() {
|
||||
name="$1"
|
||||
pattern="$2"
|
||||
if [ "$ALL" = "1" ] || grep -qE "$pattern" /tmp/changed.txt; then
|
||||
echo "$name=true" >> "$GITHUB_OUTPUT"
|
||||
echo "build $name"
|
||||
else
|
||||
echo "$name=false" >> "$GITHUB_OUTPUT"
|
||||
echo "skip $name"
|
||||
fi
|
||||
}
|
||||
|
||||
# shared/ is gone from this repository: it is the private
|
||||
# module gitea.hostxtra.co.uk/vantage/vantage-shared, pinned
|
||||
# per service in its own go.mod. A change over there reaches
|
||||
# a service when somebody bumps that pin, which is a commit
|
||||
# under the service's own directory and so already matches
|
||||
# below. There is no longer a directory whose change fans out
|
||||
# to three images, and no longer a way to ship a service
|
||||
# against a shared/ it was never built with.
|
||||
#
|
||||
# proto/ is gone too. It documents the wire types, and it
|
||||
# moved to sit beside the hand-written pb it describes, so
|
||||
# that a message added to one is added to the other in the
|
||||
# same commit. Nothing here reads it.
|
||||
flag server '^(server/|default_steps/|go\.work)'
|
||||
|
||||
# web/ uses its own directory as the build context, so
|
||||
# nothing outside it can affect it. It is the only front end
|
||||
# left here: the marketing site and the docs went to
|
||||
# vantage-site and vantage-docs, the HQ console to
|
||||
# vantage-admin.
|
||||
flag web '^web/'
|
||||
|
||||
# vantage-shared is private, so every Go build below needs a
|
||||
# credential for it. A netrc is written once here rather than a
|
||||
# token being passed as a build arg, which would survive in the
|
||||
# builder layer's history.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
@@ -21,17 +110,48 @@ jobs:
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
go mod download gitea.hostxtra.co.uk/vantage/vantage-shared
|
||||
SHARED_DIR="$(go list -m -f '{{.Dir}}' gitea.hostxtra.co.uk/vantage/vantage-shared)"
|
||||
if [ -z "$SHARED_DIR" ] || [ ! -d "$SHARED_DIR" ]; then
|
||||
echo "vantage-shared source not in the module cache: '$SHARED_DIR'" >&2
|
||||
exit 1
|
||||
fi
|
||||
swag init --generalInfo cmd/main.go --dir "./,$SHARED_DIR" --output internal/api/docs --outputTypes json --v3.1
|
||||
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
|
||||
git diff --exit-code internal/api/docs/openapi.json
|
||||
|
||||
- name: Build and push server image
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
|
||||
docker build -t "$IMAGE" -f server/Dockerfile server/
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f server/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push web image
|
||||
if: steps.changed.outputs.web == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
docker push "$IMAGE"
|
||||
|
||||
|
||||
+15
-1
@@ -1,4 +1,18 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
.env
|
||||
.env.bck
|
||||
.env.live
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
.claude
|
||||
@@ -1,39 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/config"
|
||||
agentsync "github.com/mrhid6/vantage/agent/internal/sync"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
genKey := flag.String("generate-key", "", "Generate SSH keypair and upload with this label")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if *genKey != "" {
|
||||
if err := agentsync.GenerateAndUpload(cfg, *genKey); err != nil {
|
||||
log.Fatalf("key generation failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("vantage-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
|
||||
if err := agentsync.Run(ctx, cfg, Version); err != nil {
|
||||
log.Fatalf("agent error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
module github.com/mrhid6/vantage/agent
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.64.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
|
||||
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,45 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ConfigPath = "/etc/vantage/config.yaml"
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `yaml:"server_url"`
|
||||
ServerID string `yaml:"server_id"`
|
||||
PreRegToken string `yaml:"pre_reg_token"`
|
||||
AgentToken string `yaml:"agent_token"`
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
TLS bool `yaml:"tls"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.PollInterval == 0 {
|
||||
cfg.PollInterval = 30 * time.Second
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Save(cfg *Config) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll("/etc/vantage", 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(ConfigPath, data, 0600)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package grpcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/encoding"
|
||||
)
|
||||
|
||||
func init() {
|
||||
encoding.RegisterCodec(JSONCodec{})
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
client pb.VantageClient
|
||||
}
|
||||
|
||||
func New(serverURL string, useTLS bool) (*Client, error) {
|
||||
serverURL = strings.TrimPrefix(serverURL, "https://")
|
||||
serverURL = strings.TrimPrefix(serverURL, "http://")
|
||||
|
||||
var dialOpts []grpc.DialOption
|
||||
|
||||
if useTLS {
|
||||
tlsCfg := &tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
creds := credentials.NewTLS(tlsCfg)
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.DialContext(ctx, serverURL, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
client: pb.NewVantageClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.Register(ctx, &pb.RegisterRequest{
|
||||
ServerId: serverID,
|
||||
PreRegToken: preRegToken,
|
||||
Hostname: hostname,
|
||||
IpAddress: ipAddress,
|
||||
OsInfo: osInfo,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.AgentToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
AgentVersion: version,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.PublicKeys, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.UploadGeneratedKey(ctx, &pb.UploadKeyRequest{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
PublicKey: publicKey,
|
||||
PrivateKey: privateKey,
|
||||
Label: label,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.KeyId, nil
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package grpcclient
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type JSONCodec struct{}
|
||||
|
||||
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Name() string {
|
||||
return "proto"
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec).
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CommandStream client-side interface
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageCommandStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
m := new(ServerCommand)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CommandStream server-side interface (included for completeness)
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type keyManagerCommandStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
m := new(AgentMessage)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
|
||||
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package keys
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const authorizedKeysPath = "/root/.ssh/authorized_keys"
|
||||
const sshConfigPath = "/root/.ssh/config"
|
||||
const managedConfigPath = "/root/.ssh/vantage.conf"
|
||||
const includeDirective = "Include /root/.ssh/vantage.conf"
|
||||
|
||||
func ReadAuthorizedKeys() ([]string, error) {
|
||||
data, err := os.ReadFile(authorizedKeysPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func WriteAuthorizedKeys(keys []string) error {
|
||||
dir := filepath.Dir(authorizedKeysPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
content := strings.Join(keys, "\n")
|
||||
if len(keys) > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
tmpPath := authorizedKeysPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("write tmp: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, authorizedKeysPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
|
||||
return os.Chmod(authorizedKeysPath, 0600)
|
||||
}
|
||||
|
||||
func FingerprintLines(lines []string) map[string]bool {
|
||||
fp := make(map[string]bool, len(lines))
|
||||
for _, line := range lines {
|
||||
fp[fingerprint(line)] = true
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
func StateChanged(current, desired []string) bool {
|
||||
if len(current) != len(desired) {
|
||||
return true
|
||||
}
|
||||
cur := FingerprintLines(current)
|
||||
for _, line := range desired {
|
||||
if !cur[fingerprint(line)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fingerprint(pubKey string) string {
|
||||
parts := strings.Fields(pubKey)
|
||||
if len(parts) < 2 {
|
||||
return pubKey
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return pubKey
|
||||
}
|
||||
sum := md5.Sum(raw)
|
||||
var pairs []string
|
||||
for _, b := range sum {
|
||||
pairs = append(pairs, fmt.Sprintf("%02x", b))
|
||||
}
|
||||
return "MD5:" + strings.Join(pairs, ":")
|
||||
}
|
||||
|
||||
// KeyGenOptions controls how ssh-keygen is invoked.
|
||||
type KeyGenOptions struct {
|
||||
KeyType string // ed25519 (default), rsa, ecdsa
|
||||
KeySize int // bits; used for rsa and ecdsa
|
||||
Passphrase string // empty = no passphrase
|
||||
Comment string // embedded in the public key
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates an SSH keypair and returns the public key.
|
||||
// The private key is written to keyPath; keyPath+".pub" holds the public key.
|
||||
func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
keyType := opts.KeyType
|
||||
if keyType == "" {
|
||||
keyType = "ed25519"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-t", keyType,
|
||||
"-f", keyPath,
|
||||
"-N", opts.Passphrase,
|
||||
"-C", opts.Comment,
|
||||
}
|
||||
if opts.KeySize > 0 && keyType != "ed25519" {
|
||||
args = append(args, "-b", fmt.Sprintf("%d", opts.KeySize))
|
||||
}
|
||||
|
||||
cmd := exec.Command("ssh-keygen", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ssh-keygen: %w: %s", err, out)
|
||||
}
|
||||
|
||||
pubData, err := os.ReadFile(keyPath + ".pub")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pubkey: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(pubData)), nil
|
||||
}
|
||||
|
||||
// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed
|
||||
// vantage.conf include file, and ensures ~/.ssh/config includes it.
|
||||
func AddSSHIdentity(keyPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil {
|
||||
return fmt.Errorf("mkdir .ssh: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureIncludeDirective(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read existing managed config (it may not exist yet).
|
||||
var existing string
|
||||
data, err := os.ReadFile(managedConfigPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read %s: %w", managedConfigPath, err)
|
||||
}
|
||||
existing = string(data)
|
||||
|
||||
line := "IdentityFile " + keyPath
|
||||
for _, l := range strings.Split(existing, "\n") {
|
||||
if strings.TrimSpace(l) == line {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
|
||||
if existing != "" && !strings.HasSuffix(existing, "\n") {
|
||||
existing += "\n"
|
||||
}
|
||||
updated := existing + line + "\n"
|
||||
|
||||
if err := os.WriteFile(managedConfigPath, []byte(updated), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", managedConfigPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config.
|
||||
func RemoveSSHIdentity(keyPath string) error {
|
||||
data, err := os.ReadFile(managedConfigPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", managedConfigPath, err)
|
||||
}
|
||||
|
||||
line := "IdentityFile " + keyPath
|
||||
var kept []string
|
||||
for _, l := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
|
||||
if strings.TrimSpace(l) != line {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.Join(kept, "\n")
|
||||
if len(kept) > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
if err := os.WriteFile(managedConfigPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", managedConfigPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIncludeDirective adds "Include /root/.ssh/vantage.conf" to the top
|
||||
// of ~/.ssh/config if it is not already present. The Include must appear before
|
||||
// any Host stanzas to be effective for all connections.
|
||||
func ensureIncludeDirective() error {
|
||||
data, err := os.ReadFile(sshConfigPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read %s: %w", sshConfigPath, err)
|
||||
}
|
||||
|
||||
for _, l := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(l) == includeDirective {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend the Include directive so it takes effect before any Host blocks.
|
||||
updated := includeDirective + "\n" + string(data)
|
||||
if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", sshConfigPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/agent/internal/keys"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial grpc: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Register if we have a pre-reg token
|
||||
if cfg.PreRegToken != "" {
|
||||
log.Println("registering with server...")
|
||||
hostname, _ := os.Hostname()
|
||||
ipAddress := localIP()
|
||||
osInfo := fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
agentToken, err := client.Register(cfg.ServerID, cfg.PreRegToken, hostname, ipAddress, osInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registration failed: %w", err)
|
||||
}
|
||||
|
||||
cfg.AgentToken = agentToken
|
||||
cfg.PreRegToken = ""
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
log.Println("registration successful")
|
||||
|
||||
client.Close()
|
||||
client, err = grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reconnect: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.AgentToken == "" {
|
||||
return fmt.Errorf("no agent token available — registration required")
|
||||
}
|
||||
|
||||
// Start the command stream alongside the poll loop.
|
||||
go runCommandStream(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
|
||||
current, err := keys.ReadAuthorizedKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read authorized_keys: %w", err)
|
||||
}
|
||||
|
||||
if !keys.StateChanged(current, desired) {
|
||||
log.Println("authorized_keys unchanged, skipping write")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := keys.WriteAuthorizedKeys(desired); err != nil {
|
||||
return fmt.Errorf("write authorized_keys: %w", err)
|
||||
}
|
||||
log.Printf("authorized_keys updated (%d keys)", len(desired))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCommandStream maintains a persistent bidirectional stream with the server
|
||||
// for instant command delivery. Reconnects with exponential backoff on failure.
|
||||
func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 2 * time.Minute
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := connectAndHandleStream(ctx, cfg); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
}
|
||||
} else {
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.CommandStream(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open stream: %w", err)
|
||||
}
|
||||
|
||||
if err := stream.Send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Ready: &pb.AgentReady{},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("send auth: %w", err)
|
||||
}
|
||||
|
||||
log.Println("command stream connected")
|
||||
|
||||
for {
|
||||
cmd, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("recv: %w", err)
|
||||
}
|
||||
|
||||
if cmd.GenerateKey != nil {
|
||||
go handleGenerateKey(cfg, cmd)
|
||||
}
|
||||
if cmd.DeleteKey != nil {
|
||||
go handleDeleteKey(cmd)
|
||||
}
|
||||
if cmd.UpdateAgent != nil {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
if err := keys.RemoveSSHIdentity(keyPath); err != nil {
|
||||
log.Printf("remove ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
}
|
||||
|
||||
for _, path := range []string{keyPath, keyPath + ".pub"} {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("delete key file %s (cmd=%s): %v", path, cmd.CommandId, err)
|
||||
}
|
||||
}
|
||||
log.Printf("deleted local key files for %q (cmd=%s)", label, cmd.CommandId)
|
||||
}
|
||||
|
||||
func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
arch := runtime.GOARCH // "amd64" or "arm64"
|
||||
tag := "agent%2Fv" + u.Version
|
||||
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
// Download binary
|
||||
tmpBin := "/tmp/vantage-agent-update"
|
||||
if err := downloadFile(binaryURL, tmpBin); err != nil {
|
||||
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Download and verify checksum
|
||||
checksumData, err := httpGetBytes(checksumURL)
|
||||
if err != nil {
|
||||
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := verifyChecksum(tmpBin, fmt.Sprintf("vantage-agent-linux-%s", arch), checksumData); err != nil {
|
||||
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
|
||||
os.Remove(tmpBin)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpBin, 0755); err != nil {
|
||||
log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpBin, "/usr/local/bin/vantage-agent"); err != nil {
|
||||
log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId)
|
||||
exec.Command("systemctl", "restart", "vantage-agent").Run()
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func httpGetBytes(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func verifyChecksum(filePath, filename string, checksumData []byte) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
for _, line := range strings.Split(string(checksumData), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[1] == filename {
|
||||
if fields[0] != actual {
|
||||
return fmt.Errorf("expected %s got %s", fields[0], actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no checksum entry found for %s", filename)
|
||||
}
|
||||
|
||||
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
g := cmd.GenerateKey
|
||||
label := g.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
opts := keys.KeyGenOptions{
|
||||
KeyType: g.KeyType,
|
||||
KeySize: g.KeySize,
|
||||
Passphrase: g.Passphrase,
|
||||
Comment: g.Comment,
|
||||
}
|
||||
pubKey, err := keys.GenerateKeyPair(keyPath, opts)
|
||||
if err != nil {
|
||||
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
privKeyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
log.Printf("read private key failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("dial for key upload failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
|
||||
if err != nil {
|
||||
log.Printf("key upload failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := keys.AddSSHIdentity(keyPath); err != nil {
|
||||
log.Printf("add ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
}
|
||||
log.Printf("generated and uploaded key %q (key_id=%s, cmd=%s)", label, keyID, cmd.CommandId)
|
||||
}
|
||||
|
||||
func localIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
if ipNet.IP.To4() != nil {
|
||||
return ipNet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GenerateAndUpload generates an SSH keypair and uploads the public key to the server.
|
||||
func GenerateAndUpload(cfg *config.Config, label string) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privKeyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
|
||||
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := keys.AddSSHIdentity(keyPath); err != nil {
|
||||
log.Printf("add ssh identity: %v", err)
|
||||
}
|
||||
log.Printf("uploaded generated key %s (key_id=%s)", label, keyID)
|
||||
return nil
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
# Vantage
|
||||
|
||||
A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Next.js Frontend │
|
||||
│ - Upload/manage keys │
|
||||
│ - Add servers (install script) │
|
||||
│ - Assign/revoke per server │
|
||||
└────────────┬────────────────────┘
|
||||
│ REST
|
||||
┌────────────▼────────────────────┐
|
||||
│ Go Backend │
|
||||
│ - REST API for frontend │
|
||||
│ - gRPC server for agents │
|
||||
│ - MongoDB │
|
||||
└────────────┬────────────────────┘
|
||||
│ gRPC (TLS)
|
||||
┌────────────▼────────────────────┐
|
||||
│ Go Agent (per server) │
|
||||
│ - Polls every 30s │
|
||||
│ - Rewrites authorized_keys │
|
||||
│ - Can generate SSH keypairs │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
vantage/
|
||||
├── agent/
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── config/
|
||||
│ ├── grpc/
|
||||
│ ├── keys/
|
||||
│ └── sync/
|
||||
├── server/
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── api/ # REST handlers for Next.js
|
||||
│ ├── grpc/ # gRPC server implementation
|
||||
│ ├── models/ # MongoDB models
|
||||
│ └── services/
|
||||
│ ├── keys.go
|
||||
│ ├── servers.go
|
||||
│ └── sync.go # builds desired state per server
|
||||
├── web/
|
||||
│ ├── app/
|
||||
│ └── components/
|
||||
├── proto/
|
||||
│ └── vantage/v1/vantage.proto
|
||||
├── deploy/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── agent.service
|
||||
└── .gitea/
|
||||
└── workflows/
|
||||
├── agent-release.yml
|
||||
└── server-deploy.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## gRPC API
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package vantage.v1;
|
||||
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
string server_id = 1;
|
||||
string pre_reg_token = 2;
|
||||
string hostname = 3;
|
||||
string ip_address = 4;
|
||||
string os_info = 5;
|
||||
}
|
||||
message RegisterResponse {
|
||||
string agent_token = 1;
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
}
|
||||
message SyncResponse {
|
||||
repeated string public_keys = 1; // full authorized_keys lines
|
||||
}
|
||||
|
||||
message UploadKeyRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string public_key = 3;
|
||||
string label = 4;
|
||||
}
|
||||
message UploadKeyResponse {
|
||||
string key_id = 1;
|
||||
}
|
||||
```
|
||||
|
||||
No streaming — polling only. Poll interval: **30 seconds**.
|
||||
|
||||
---
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
### `servers`
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"server_id": "uuid",
|
||||
"hostname": "proxmox-node-1",
|
||||
"ip_address": "10.10.10.5",
|
||||
"os_info": "Ubuntu 24.04",
|
||||
"pre_reg_token": "abc123",
|
||||
"pre_reg_expires": "ISODate",
|
||||
"agent_token_hash": "sha256...",
|
||||
"status": "pending|active|offline",
|
||||
"last_seen": "ISODate",
|
||||
"created_at": "ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
- `pre_reg_token` is cleared after the agent successfully calls `Register()`
|
||||
- `agent_token_hash` stores SHA-256 of the token — never plaintext
|
||||
- `status` transitions: `pending` → `active` on first `Register()`, `offline` if last_seen exceeds threshold
|
||||
|
||||
### `keys`
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"key_id": "uuid",
|
||||
"label": "dom-macbook",
|
||||
"public_key": "ssh-ed25519 AAAA...",
|
||||
"fingerprint": "SHA256:...",
|
||||
"source": "uploaded|generated",
|
||||
"generated_by_server_id": "uuid",
|
||||
"created_at": "ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
### `assignments`
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"key_id": "uuid",
|
||||
"server_id": "uuid",
|
||||
"assigned_at": "ISODate",
|
||||
"revoked_at": "ISODate | null"
|
||||
}
|
||||
```
|
||||
|
||||
- `revoked_at: null` = key is active on that server
|
||||
- Revocation is soft — set `revoked_at`, agent picks it up on next poll
|
||||
|
||||
---
|
||||
|
||||
## Agent Lifecycle
|
||||
|
||||
### Config file — `/etc/vantage/config.yaml`
|
||||
|
||||
```yaml
|
||||
server_url: "vantage.yourdomain.com:9090"
|
||||
server_id: "<uuid>"
|
||||
pre_reg_token: "<token>" # removed after first successful Register()
|
||||
agent_token: "" # written by agent after Register()
|
||||
poll_interval: 30s
|
||||
tls: true
|
||||
```
|
||||
|
||||
Config file permissions: `0600`. Config directory: `0700`.
|
||||
|
||||
### Startup flow
|
||||
|
||||
```
|
||||
1. Load config
|
||||
2. If pre_reg_token present:
|
||||
→ call Register(server_id, pre_reg_token, hostname, ip, os_info)
|
||||
→ save returned agent_token to config
|
||||
→ delete pre_reg_token from config
|
||||
3. Enter poll loop
|
||||
```
|
||||
|
||||
### Poll loop (every 30s)
|
||||
|
||||
```
|
||||
1. Call SyncKeys(server_id, agent_token)
|
||||
2. Receive []public_keys
|
||||
3. Compute fingerprints of current /root/.ssh/authorized_keys
|
||||
4. If state unchanged → skip write
|
||||
5. If changed:
|
||||
→ write to /root/.ssh/authorized_keys.tmp
|
||||
→ os.Rename() to /root/.ssh/authorized_keys (atomic)
|
||||
→ chmod 0600
|
||||
```
|
||||
|
||||
### Key generation (on demand)
|
||||
|
||||
- Triggered by a flag or API call from the server
|
||||
- Runs `ssh-keygen` via `exec.Command`
|
||||
- Uploads public key via `UploadGeneratedKey()`
|
||||
- Private key stays local on the machine
|
||||
|
||||
### Systemd unit — `/etc/systemd/system/vantage-agent.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Registration Flow
|
||||
|
||||
1. Click **Add Server** in the UI
|
||||
2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id`
|
||||
3. UI displays a one-liner install command with copy button:
|
||||
```bash
|
||||
curl -fsSL https://vantage.yourdomain.com/install | \
|
||||
bash -s -- --server-id=<id> --token=<token>
|
||||
```
|
||||
4. Install script:
|
||||
- Detects arch (`amd64` / `arm64`)
|
||||
- Downloads agent binary from Gitea release
|
||||
- Verifies SHA-256 checksum
|
||||
- Writes `/etc/vantage/config.yaml`
|
||||
- Installs and starts systemd unit
|
||||
5. On first `SyncKeys` call, server marks status as `active`
|
||||
|
||||
The backend serves `/install` dynamically, injecting the latest agent version by querying the Gitea API for the most recent `agent/v*` release tag.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent)
|
||||
- Agent authenticates with a per-server token stored at `/etc/vantage/config.yaml` (`0600`)
|
||||
- Server stores `SHA-256(agent_token)` — never the plaintext token
|
||||
- Private keys generated by agents are encrypted at rest in MongoDB (AES-256)
|
||||
- `authorized_keys` written as `0600`, owned by root
|
||||
- Pre-registration tokens are short-lived (1 hour) and single-use
|
||||
- Agent runs as `root` (required for `/root/.ssh/authorized_keys` writes)
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| `/servers` | List all servers, online/offline status badge, last seen timestamp |
|
||||
| `/servers/new` | Displays the one-liner install script with copy button |
|
||||
| `/servers/[id]` | Keys installed on this server, trigger key generation, remove server |
|
||||
| `/keys` | All keys — label, fingerprint, source, assigned count |
|
||||
| `/keys/[id]` | Assign key to servers, revoke per server |
|
||||
|
||||
---
|
||||
|
||||
## CI/CD — Gitea Actions
|
||||
|
||||
### Agent release — `.gitea/workflows/agent-release.yml`
|
||||
|
||||
Triggered by a `agent/v*` tag. Cross-compiles for `linux/amd64` and `linux/arm64`, creates a Gitea release with binaries and checksums.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "agent/v*"
|
||||
```
|
||||
|
||||
Build command:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
```
|
||||
|
||||
Release assets:
|
||||
|
||||
- `vantage-agent-linux-amd64`
|
||||
- `vantage-agent-linux-arm64`
|
||||
- `checksums.txt`
|
||||
|
||||
### Server deploy — `.gitea/workflows/server-deploy.yml`
|
||||
|
||||
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
|
||||
|
||||
```bash
|
||||
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
|
||||
```
|
||||
|
||||
### Tagging convention
|
||||
|
||||
```bash
|
||||
# Release a new agent version
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0
|
||||
|
||||
# Server + web deploy automatically on push to main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Required Gitea secrets / variables
|
||||
|
||||
| Name | Type | Value |
|
||||
| ------------------- | -------- | ------------------------------------------ |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token with `write:release` scope |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token with `write:packages` scope |
|
||||
| `DEPLOY_HOST` | Secret | IP/hostname of the server VM |
|
||||
| `DEPLOY_USER` | Secret | SSH user for deploy |
|
||||
| `DEPLOY_SSH_KEY` | Secret | Private key for deploy SSH |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **gRPC over REST for agent communication** — strong typing, easy versioning, bi-directional streaming available later if push-based updates are needed
|
||||
- **Poll-only, no streaming** — 30s interval is sufficient for a homelab; simplifies agent implementation
|
||||
- **Outbound-only agent connections** — no inbound firewall holes required on managed servers
|
||||
- **Atomic `authorized_keys` rewrite** — write to `.tmp` then `os.Rename()` prevents partial writes
|
||||
- **Fingerprint diffing before write** — avoids unnecessary disk writes on unchanged state
|
||||
- **Soft revocation** — `revoked_at` timestamp rather than hard deletes; preserves audit history
|
||||
- **root only** — manages `/root/.ssh/authorized_keys` only; no per-user key management
|
||||
- **Gitea releases for agent binaries** — slots into existing act_runner CI pipeline; install script queries Gitea API for latest version at serve time
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Apply Package Updates",
|
||||
"description": "Apply all pending OS package updates. Supports apt, dnf, yum, zypper, apk and pacman.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nif command -v apt-get >/dev/null 2>&1; then\n export DEBIAN_FRONTEND=noninteractive\n apt-get update -qq && apt-get -y -qq upgrade\nelif command -v dnf >/dev/null 2>&1; then\n dnf -y upgrade\nelif command -v yum >/dev/null 2>&1; then\n yum -y update\nelif command -v zypper >/dev/null 2>&1; then\n zypper --non-interactive update\nelif command -v apk >/dev/null 2>&1; then\n apk update && apk upgrade\nelif command -v pacman >/dev/null 2>&1; then\n pacman -Syu --noconfirm\nelse\n echo \"no supported package manager found\"\n exit 1\nfi\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"package update failed\"\n exit 1\nfi\necho \"packages up to date\"\n# Debian and Ubuntu drop this file when a new kernel or libc needs a restart.\n# Reported rather than acted on: rebooting a fleet is a decision, not a detail.\nif [ -f /var/run/reboot-required ]; then\n echo \"REBOOT_REQUIRED=true\" >> $WORKFLOW_ENV\n echo \"a reboot is required to finish applying updates\"\nelse\n echo \"REBOOT_REQUIRED=false\" >> $WORKFLOW_ENV\nfi",
|
||||
"declared_outputs": [
|
||||
"REBOOT_REQUIRED"
|
||||
],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Check Port Is Listening",
|
||||
"description": "Fail unless something is listening on a TCP port.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nhost=\"${host:-127.0.0.1}\"\nif command -v nc >/dev/null 2>&1; then\n nc -z -w 5 \"$host\" \"$port\" >/dev/null 2>&1\n ok=$?\nelse\n # bash builds /dev/tcp in, so this needs nothing installed.\n timeout 5 bash -c \"cat < /dev/null > /dev/tcp/$host/$port\" >/dev/null 2>&1\n ok=$?\nfi\nif [ $ok -ne 0 ]; then\n echo \"PORT_OPEN=false\" >> $WORKFLOW_ENV\n echo \"nothing listening on $host:$port\"\n exit 1\nfi\necho \"PORT_OPEN=true\" >> $WORKFLOW_ENV\necho \"$host:$port is open\"",
|
||||
"declared_outputs": [
|
||||
"PORT_OPEN"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "host",
|
||||
"default": "127.0.0.1",
|
||||
"description": "host to test"
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"default": "",
|
||||
"description": "TCP port to test"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Copy File/Directory",
|
||||
"description": "Copy a file or directory, preserving mode, ownership and timestamps.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nif [ ! -e \"$source\" ]; then\n echo \"source $source does not exist\"\n exit 1\nfi\ncp -a \"$source\" \"$destination\" || { echo \"failed to copy $source to $destination\"; exit 1; }\necho \"copied $source to $destination\"\necho \"DEST_PATH=$destination\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"DEST_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"default": "",
|
||||
"description": "path to copy from"
|
||||
},
|
||||
{
|
||||
"name": "destination",
|
||||
"default": "",
|
||||
"description": "path to copy to"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Create Directory",
|
||||
"description": "Create a directory, including any missing parents.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nmkdir -p \"$path\" || { echo \"failed to create $path\"; exit 1; }\nif [ -n \"${mode:-}\" ]; then\n chmod \"$mode\" \"$path\" || { echo \"failed to set mode $mode on $path\"; exit 1; }\nfi\necho \"created $path\"\necho \"DIR_PATH=$path\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"DIR_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "directory to create"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"default": "",
|
||||
"description": "optional octal mode, e.g. 0750"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Delete File/Directory",
|
||||
"description": "Delete a path. Refuses the root filesystem and an empty value.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\n# A step that runs as root on every server in a selector has to refuse the\n# one input that would wipe the fleet. An unset variable expands to empty,\n# so the empty case is the accident this actually guards against.\ncase \"$path\" in\n \"\"|\"/\"|\"/.\"|\"/..\")\n echo \"refusing to delete '$path'\"\n exit 1\n ;;\nesac\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist, nothing to do\"\n exit 0\nfi\nrm -rf \"$path\" || { echo \"failed to delete $path\"; exit 1; }\necho \"deleted $path\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "path to delete"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Disk Usage Report",
|
||||
"description": "Report usage for a mount point and fail past a threshold.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nmount=\"${mountPoint:-/}\"\nlimit=\"${maxPercent:-90}\"\ndf -h \"$mount\"\nused=$(df --output=pcent \"$mount\" | tail -1 | tr -dc \"0-9\")\navail=$(df -h --output=avail \"$mount\" | tail -1 | tr -d \" \")\necho \"DISK_USED_PERCENT=$used\" >> $WORKFLOW_ENV\necho \"DISK_AVAILABLE=$avail\" >> $WORKFLOW_ENV\nif [ \"$used\" -ge \"$limit\" ]; then\n echo \"$mount is ${used}% full, at or over the ${limit}% limit\"\n exit 1\nfi\necho \"$mount is ${used}% full, ${avail} available\"",
|
||||
"declared_outputs": [
|
||||
"DISK_USED_PERCENT",
|
||||
"DISK_AVAILABLE"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "mountPoint",
|
||||
"default": "/",
|
||||
"description": "mount point to measure"
|
||||
},
|
||||
{
|
||||
"name": "maxPercent",
|
||||
"default": "90",
|
||||
"description": "fail at or above this percentage"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Docker Compose Pull and Up",
|
||||
"description": "Pull the latest images for a compose project and recreate its containers.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\ncd \"$projectDir\" || { echo \"no such directory: $projectDir\"; exit 1; }\nif docker compose version >/dev/null 2>&1; then\n dc=\"docker compose\"\nelif command -v docker-compose >/dev/null 2>&1; then\n dc=\"docker-compose\"\nelse\n echo \"docker compose is not installed\"\n exit 1\nfi\n$dc pull || { echo \"pull failed\"; exit 1; }\n$dc up -d --remove-orphans || { echo \"up failed\"; exit 1; }\n$dc ps",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "projectDir",
|
||||
"default": "",
|
||||
"description": "directory holding docker-compose.yml"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Download File (Linux)",
|
||||
"description": "Download a file over HTTP to a local path",
|
||||
"interpreter": "bash",
|
||||
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
|
||||
"declared_outputs": ["FILE_PATH"],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Enable Linux Service",
|
||||
"description": "Enable a systemd unit so it starts on boot.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\necho \"enabling service $serviceName\"\nsystemctl enable \"$serviceName\" || { echo \"failed to enable $serviceName\"; exit 1; }\necho \"$serviceName enabled\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "systemd unit to enable"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Extract Archive",
|
||||
"description": "Extract a tar, tar.gz, tar.bz2, tar.xz or zip archive into a directory.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\ndest=\"${destination:-.}\"\nif [ ! -f \"$archive\" ]; then\n echo \"archive $archive does not exist\"\n exit 1\nfi\nmkdir -p \"$dest\"\ncase \"$archive\" in\n *.tar.gz|*.tgz) tar -xzf \"$archive\" -C \"$dest\" ;;\n *.tar.bz2|*.tbz2) tar -xjf \"$archive\" -C \"$dest\" ;;\n *.tar.xz|*.txz) tar -xJf \"$archive\" -C \"$dest\" ;;\n *.tar) tar -xf \"$archive\" -C \"$dest\" ;;\n *.zip)\n command -v unzip >/dev/null 2>&1 || { echo \"unzip is not installed\"; exit 1; }\n unzip -oq \"$archive\" -d \"$dest\"\n ;;\n *)\n echo \"unsupported archive type: $archive\"\n exit 1\n ;;\nesac\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to extract $archive\"\n exit 1\nfi\necho \"extracted $archive into $dest\"\necho \"EXTRACT_DIR=$dest\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"EXTRACT_DIR"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "archive",
|
||||
"default": "",
|
||||
"description": "archive file to extract"
|
||||
},
|
||||
{
|
||||
"name": "destination",
|
||||
"default": ".",
|
||||
"description": "directory to extract into"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Get Host Name",
|
||||
"description": "Gets the agents hostname",
|
||||
"interpreter": "bash",
|
||||
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
|
||||
"declared_outputs": ["HOSTNAME"],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "HTTP Health Check",
|
||||
"description": "Request a URL and fail unless it answers with the expected status.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nexpected=\"${expectedStatus:-200}\"\nattempts=\"${retries:-3}\"\ndelay=\"${retryDelay:-5}\"\nstatus=\"\"\ni=1\n# Retries live in the script rather than in on_failure: a service coming up\n# after a restart wants a few seconds, not a whole step re-dispatched.\nwhile [ \"$i\" -le \"$attempts\" ]; do\n status=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$url\" || echo \"000\")\n echo \"attempt $i: $url returned $status\"\n if [ \"$status\" = \"$expected\" ]; then\n break\n fi\n i=$(( i + 1 ))\n if [ \"$i\" -le \"$attempts\" ]; then sleep \"$delay\"; fi\ndone\necho \"HTTP_STATUS=$status\" >> $WORKFLOW_ENV\nif [ \"$status\" != \"$expected\" ]; then\n echo \"$url returned $status, expected $expected\"\n exit 1\nfi\necho \"$url is healthy\"",
|
||||
"declared_outputs": [
|
||||
"HTTP_STATUS"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": "URL to request"
|
||||
},
|
||||
{
|
||||
"name": "expectedStatus",
|
||||
"default": "200",
|
||||
"description": "HTTP status that counts as healthy"
|
||||
},
|
||||
{
|
||||
"name": "retries",
|
||||
"default": "3",
|
||||
"description": "how many attempts before failing"
|
||||
},
|
||||
{
|
||||
"name": "retryDelay",
|
||||
"default": "5",
|
||||
"description": "seconds between attempts"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "List Directory",
|
||||
"description": "Lists the files in the specified path",
|
||||
"interpreter": "bash",
|
||||
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "./",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Memory Usage Report",
|
||||
"description": "Report memory usage as a percentage of total.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nfree -h\ntotal=$(free -m | awk \"/^Mem:/ {print \\$2}\")\nused=$(free -m | awk \"/^Mem:/ {print \\$3}\")\npct=$(( used * 100 / total ))\necho \"MEM_USED_PERCENT=$pct\" >> $WORKFLOW_ENV\necho \"MEM_USED_MB=$used\" >> $WORKFLOW_ENV\necho \"memory ${pct}% used (${used}MB of ${total}MB)\"",
|
||||
"declared_outputs": [
|
||||
"MEM_USED_PERCENT",
|
||||
"MEM_USED_MB"
|
||||
],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Reboot Server",
|
||||
"description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\ndelay=\"${delayMinutes:-1}\"\n# Scheduled rather than immediate on purpose: `shutdown -r now` kills the\n# agent before it can report, and the run records a failure on a server\n# that did exactly what it was told.\necho \"rebooting in $delay minute(s)\"\nshutdown -r \"+$delay\" \"Reboot requested by Vantage\" || { echo \"failed to schedule a reboot\"; exit 1; }",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "delayMinutes",
|
||||
"default": "1",
|
||||
"description": "minutes to wait before rebooting"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Restart Linux Service",
|
||||
"description": "Restart a systemd unit and fail if it does not come back up.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\necho \"restarting service $serviceName\"\nsystemctl restart \"$serviceName\" || { echo \"failed to restart $serviceName\"; exit 1; }\nsystemctl is-active --quiet \"$serviceName\" || {\n echo \"$serviceName did not come back up\"\n systemctl status \"$serviceName\" --no-pager --lines=20 || true\n exit 1\n}\necho \"$serviceName is active\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "systemd unit to restart, e.g. nginx"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Linux Service Status",
|
||||
"description": "Report whether a systemd unit is active and enabled. Does not fail on a stopped unit.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nstate=$(systemctl is-active \"$serviceName\" 2>/dev/null || true)\nenabled=$(systemctl is-enabled \"$serviceName\" 2>/dev/null || true)\necho \"$serviceName: state=$state enabled=$enabled\"\necho \"SERVICE_STATE=$state\" >> $WORKFLOW_ENV\necho \"SERVICE_ENABLED=$enabled\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"SERVICE_STATE",
|
||||
"SERVICE_ENABLED"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "systemd unit to inspect"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Set Permissions and Ownership",
|
||||
"description": "Set the mode and optionally the owner of a path.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\nrecurse=\"\"\nif [ \"${recursive:-false}\" = \"true\" ]; then\n recurse=\"-R\"\nfi\nif [ -n \"${mode:-}\" ]; then\n chmod $recurse \"$mode\" \"$path\" || { echo \"failed to set mode\"; exit 1; }\n echo \"set mode $mode on $path\"\nfi\nif [ -n \"${owner:-}\" ]; then\n chown $recurse \"$owner\" \"$path\" || { echo \"failed to set owner\"; exit 1; }\n echo \"set owner $owner on $path\"\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "path to change"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"default": "",
|
||||
"description": "octal mode, e.g. 0640"
|
||||
},
|
||||
{
|
||||
"name": "owner",
|
||||
"default": "",
|
||||
"description": "owner, e.g. root:root"
|
||||
},
|
||||
{
|
||||
"name": "recursive",
|
||||
"default": "false",
|
||||
"description": "true to apply recursively"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Start Linux Service",
|
||||
"description": "Start a linux systemd service",
|
||||
"interpreter": "bash",
|
||||
"script": "echo \"starting service $serviceName\"\nsystemctl start $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to start service\"\n exit 1\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Stop Linux Service",
|
||||
"description": "Stops a linux systemd service",
|
||||
"interpreter": "bash",
|
||||
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Tail Log File",
|
||||
"description": "Print the last N lines of a file, for reading a log after a deployment step.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nlines=\"${lines:-50}\"\nif [ ! -f \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\necho \"last $lines lines of $path:\"\ntail -n \"$lines\" \"$path\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "log file to read"
|
||||
},
|
||||
{
|
||||
"name": "lines",
|
||||
"default": "50",
|
||||
"description": "how many lines to print"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "TLS Certificate Expiry",
|
||||
"description": "Report days remaining on a TLS certificate and fail under a threshold.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nport=\"${port:-443}\"\nmin=\"${minDays:-14}\"\ncommand -v openssl >/dev/null 2>&1 || { echo \"openssl is not installed\"; exit 1; }\n# -servername sends SNI, without which a shared host returns the wrong\n# certificate and the expiry reported here belongs to someone else.\nend=$(echo | openssl s_client -servername \"$host\" -connect \"$host:$port\" 2>/dev/null \\\n | openssl x509 -noout -enddate | cut -d= -f2)\nif [ -z \"$end\" ]; then\n echo \"could not read a certificate from $host:$port\"\n exit 1\nfi\nendEpoch=$(date -d \"$end\" +%s)\nnowEpoch=$(date +%s)\ndays=$(( (endEpoch - nowEpoch) / 86400 ))\necho \"CERT_DAYS_REMAINING=$days\" >> $WORKFLOW_ENV\necho \"CERT_EXPIRES=$end\" >> $WORKFLOW_ENV\necho \"$host:$port expires in $days days ($end)\"\nif [ \"$days\" -lt \"$min\" ]; then\n echo \"fewer than $min days remaining\"\n exit 1\nfi",
|
||||
"declared_outputs": [
|
||||
"CERT_DAYS_REMAINING",
|
||||
"CERT_EXPIRES"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "host",
|
||||
"default": "",
|
||||
"description": "hostname to check"
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"default": "443",
|
||||
"description": "TLS port"
|
||||
},
|
||||
{
|
||||
"name": "minDays",
|
||||
"default": "14",
|
||||
"description": "fail below this many days remaining"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Download File (Windows)",
|
||||
"description": "Download a file over HTTP to a local path.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$url = $env:url\n$dest = if ($env:destination) { $env:destination } else { Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) }\nWrite-Output \"downloading $url\"\ntry {\n # -UseBasicParsing keeps this working on Server Core, where the IE\n # engine Invoke-WebRequest otherwise reaches for is not installed.\n Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing\n} catch {\n Write-Output \"failed to download: $_\"\n exit 1\n}\nWrite-Output \"saved to $dest\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"FILE_PATH=$dest\"",
|
||||
"declared_outputs": [
|
||||
"FILE_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": "URL to download"
|
||||
},
|
||||
{
|
||||
"name": "destination",
|
||||
"default": "",
|
||||
"description": "where to save it; defaults to a file in TEMP"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Reboot Windows Server",
|
||||
"description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$delay = if ($env:delaySeconds) { [int]$env:delaySeconds } else { 60 }\nWrite-Output \"rebooting in $delay second(s)\"\n& shutdown.exe /r /t $delay /c \"Reboot requested by Vantage\"\nif ($LASTEXITCODE -ne 0) {\n Write-Output \"failed to schedule a reboot\"\n exit 1\n}",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "delaySeconds",
|
||||
"default": "60",
|
||||
"description": "seconds to wait before rebooting"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Restart Windows Service",
|
||||
"description": "Restart a Windows service and fail if it does not come back up.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\nWrite-Output \"restarting service $name\"\ntry {\n Restart-Service -Name $name -Force\n} catch {\n Write-Output \"failed to restart ${name}: $_\"\n exit 1\n}\n$svc = Get-Service -Name $name\nif ($svc.Status -ne \"Running\") {\n Write-Output \"$name is $($svc.Status), not Running\"\n exit 1\n}\nWrite-Output \"$name is running\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "Windows service name to restart"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Windows Disk Report",
|
||||
"description": "Report free space on a drive and fail past a usage threshold.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$letter = if ($env:driveLetter) { $env:driveLetter } else { \"C\" }\n$limit = if ($env:maxPercent) { [int]$env:maxPercent } else { 90 }\n$d = Get-PSDrive -Name $letter -ErrorAction SilentlyContinue\nif ($null -eq $d) {\n Write-Output \"drive $letter not found\"\n exit 1\n}\n$total = $d.Used + $d.Free\n$pct = [math]::Round(($d.Used / $total) * 100)\n$freeGb = [math]::Round($d.Free / 1GB, 1)\nWrite-Output \"${letter}: is $pct% full, $freeGb GB free\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_USED_PERCENT=$pct\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_FREE_GB=$freeGb\"\nif ($pct -ge $limit) {\n Write-Output \"at or over the $limit% limit\"\n exit 1\n}",
|
||||
"declared_outputs": [
|
||||
"DISK_USED_PERCENT",
|
||||
"DISK_FREE_GB"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "driveLetter",
|
||||
"default": "C",
|
||||
"description": "drive letter, without a colon"
|
||||
},
|
||||
{
|
||||
"name": "maxPercent",
|
||||
"default": "90",
|
||||
"description": "fail at or above this percentage"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Windows Service Status",
|
||||
"description": "Report a Windows service's status and start type. Does not fail on a stopped service.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\n$svc = Get-Service -Name $name -ErrorAction SilentlyContinue\nif ($null -eq $svc) {\n Write-Output \"$name is not installed\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=missing\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=none\"\n exit 0\n}\nWrite-Output \"${name}: $($svc.Status), start type $($svc.StartType)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=$($svc.Status)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=$($svc.StartType)\"",
|
||||
"declared_outputs": [
|
||||
"SERVICE_STATE",
|
||||
"SERVICE_START_TYPE"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "Windows service name to inspect"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=vantage-agent
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=false
|
||||
ProtectHome=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.1.0
|
||||
appVersion: "1.0.8"
|
||||
@@ -0,0 +1,79 @@
|
||||
Vantage has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
|
||||
|
||||
Services created:
|
||||
{{- if .Values.redis.enabled }}
|
||||
- {{ .Release.Name }}-redis (ClusterIP {{ .Values.redis.port }})
|
||||
{{- else }}
|
||||
- Redis: not deployed, using external {{ .Values.redis.addr }}
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
- {{ .Release.Name }}-mongo (ClusterIP {{ .Values.mongo.port }})
|
||||
{{- else }}
|
||||
- MongoDB: not deployed, using the external server.env.mongoUri
|
||||
{{- end }}
|
||||
- {{ .Release.Name }}-guacd ({{ .Values.guacd.service.type }} {{ .Values.guacd.service.port }})
|
||||
- {{ .Release.Name }}-server ({{ .Values.server.service.type }} http:{{ .Values.server.service.httpPort }} grpc:{{ .Values.server.service.grpcPort }})
|
||||
- {{ .Release.Name }}-web ({{ .Values.web.service.type }} {{ .Values.web.service.port }})
|
||||
|
||||
Scaling (server.replicaCount / web.replicaCount):
|
||||
- Both scale. Pin the image tags first — replicas on different builds serve
|
||||
mismatched web asset hashes, and mixed server versions share one bus.
|
||||
- server replicas route agent commands, step results and console relays to
|
||||
each other over Redis, so every replica must use the SAME Redis. Workflow
|
||||
logs are in MongoDB, not on a volume.
|
||||
- Background work (monitor scheduler, Free reaper, log and audit retention,
|
||||
the offline sweep) runs on one replica at a time under a Redis leader lock.
|
||||
- server.persistence must be off to scale past one replica on a ReadWriteOnce
|
||||
volume. Nothing writes to it any more.
|
||||
{{- if gt (int .Values.server.replicaCount) 1 }}
|
||||
- Console relays are reached by pod IP; guacd must be able to dial pod IPs
|
||||
directly (it can, inside the cluster network).
|
||||
{{- end }}
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
- Migrations run in the {{ .Release.Name }}-migrate Job before each upgrade;
|
||||
the pods skip them. Its logs are kept: kubectl logs job/{{ .Release.Name }}-migrate
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
Ingress (Traefik):
|
||||
- Browsers: https://{{ .Values.ingress.web.host }}
|
||||
{{- range .Values.ingress.web.extraHosts }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go to the server; everything else to web.
|
||||
web proxies nothing, so those paths must be routed here or by a terminator
|
||||
in front of this ingress.
|
||||
{{- if .Values.ingress.grpc.enabled }}
|
||||
- Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS)
|
||||
Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}.
|
||||
Point DNS for both hostnames at the Traefik load balancer.
|
||||
{{- if not .Values.ingress.tls.enabled }}
|
||||
- WARNING: ingress.tls.enabled is false. Agent tokens and session cookies
|
||||
would cross the network in clear.
|
||||
{{- else if and (not .Values.ingress.tls.certResolver) (not .Values.ingress.tls.secretName) }}
|
||||
- WARNING: TLS is on but neither ingress.tls.secretName nor
|
||||
ingress.tls.certResolver is set, so Traefik will serve its self-signed
|
||||
default certificate, which no agent and no browser will trust.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
By default the server/web/guacd services are ClusterIP only (no host port publishing,
|
||||
unlike the original docker-compose file). To expose them externally, set
|
||||
server.service.type / web.service.type / guacd.service.type to NodePort or LoadBalancer,
|
||||
or add an Ingress on top of the -web and -server services.
|
||||
|
||||
Quick access via port-forward, e.g.:
|
||||
kubectl port-forward svc/{{ .Release.Name }}-web {{ .Values.web.service.port }}:{{ .Values.web.service.port }}
|
||||
kubectl port-forward svc/{{ .Release.Name }}-server {{ .Values.server.service.httpPort }}:{{ .Values.server.service.httpPort }}
|
||||
{{- if not .Values.backup.enabled }}
|
||||
|
||||
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
|
||||
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
|
||||
but your own configuration — a database restored without it is permanently
|
||||
unreadable.
|
||||
|
||||
Set backup.enabled, backup.image and backup.pvcName, and store
|
||||
KEY_ENCRYPTION_KEY somewhere that survives this cluster.
|
||||
{{- end }}
|
||||
@@ -0,0 +1,102 @@
|
||||
{{/*
|
||||
Common name helpers
|
||||
*/}}
|
||||
{{- define "vantage.fullname" -}}
|
||||
{{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "vantage.labels" -}}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
vantage.server.env renders the server container's environment.
|
||||
|
||||
It lives here because two workloads need it identically: the Deployment and the
|
||||
pre-upgrade migration Job. A Job that connected to a different database than the
|
||||
pods it migrates for would be worse than no Job at all, so there is one copy and
|
||||
both read it.
|
||||
*/}}
|
||||
{{- define "vantage.server.env" -}}
|
||||
- name: MONGO_URI
|
||||
{{- $mongoUri := tpl .Values.server.env.mongoUri . }}
|
||||
{{- if and (not .Values.mongo.enabled) (contains (printf "%s-mongo" .Release.Name) $mongoUri) }}
|
||||
{{- fail "mongo.enabled is false, so server.env.mongoUri must point at an external MongoDB rather than the in-chart one" }}
|
||||
{{- end }}
|
||||
value: {{ $mongoUri | quote }}
|
||||
- name: REDIS_ADDR
|
||||
{{- if .Values.redis.enabled }}
|
||||
value: "{{ .Release.Name }}-redis:{{ .Values.redis.port }}"
|
||||
{{- else }}
|
||||
{{- if not .Values.redis.addr }}
|
||||
{{- fail "redis.enabled is false, so redis.addr must be set to an external Redis host:port" }}
|
||||
{{- end }}
|
||||
value: {{ .Values.redis.addr | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.auth.existingSecret }}
|
||||
- name: REDIS_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.auth.existingSecret }}
|
||||
key: {{ .Values.redis.auth.usernameKey }}
|
||||
optional: true
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.auth.existingSecret }}
|
||||
key: {{ .Values.redis.auth.passwordKey }}
|
||||
{{- else }}
|
||||
{{- if .Values.redis.auth.username }}
|
||||
- name: REDIS_USERNAME
|
||||
value: {{ .Values.redis.auth.username | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.auth.password }}
|
||||
- name: REDIS_PASSWORD
|
||||
value: {{ .Values.redis.auth.password | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- name: GRPC_HOST
|
||||
value: {{ .Values.server.env.grpcHost | quote }}
|
||||
- name: GRPC_PORT
|
||||
value: {{ .Values.server.service.grpcPort | quote }}
|
||||
- name: HTTP_PORT
|
||||
value: {{ .Values.server.service.httpPort | quote }}
|
||||
- name: KEY_ENCRYPTION_KEY
|
||||
value: {{ .Values.server.env.keyEncryptionKey | quote }}
|
||||
- name: GUACD_ADDR
|
||||
value: "{{ .Release.Name }}-guacd:{{ .Values.guacd.service.port }}"
|
||||
- name: APP_ROOT_LABEL
|
||||
value: {{ .Values.server.env.appRootLabel | quote }}
|
||||
- name: PROXY_ADVERTISE_HOST
|
||||
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
|
||||
- name: PROXY_LISTEN_HOST
|
||||
value: {{ .Values.server.env.proxyListenHost | quote }}
|
||||
- name: TRUSTED_PROXIES
|
||||
value: {{ .Values.server.env.trustedProxies | quote }}
|
||||
{{- if eq .Values.server.env.deploymentType "cloud" }}
|
||||
- name: VANTAGE_DEPLOYMENT
|
||||
value: "cloud"
|
||||
- name: FREE_INSTANCE_REAP_AFTER
|
||||
value: {{ .Values.server.env.freeInstanceReapAfter | quote }}
|
||||
{{- end }}
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
vantage.backup.env renders the environment vantagectl needs.
|
||||
|
||||
It reads the SAME values the server does rather than taking its own, because a
|
||||
backup that connected to a different database, or stamped a fingerprint of a
|
||||
different key, than the deployment it is backing up would be worse than no
|
||||
backup: it would look like one.
|
||||
*/}}
|
||||
{{- define "vantage.backup.env" -}}
|
||||
- name: MONGO_URI
|
||||
value: {{ tpl .Values.server.env.mongoUri . | quote }}
|
||||
- name: KEY_ENCRYPTION_KEY
|
||||
value: {{ .Values.server.env.keyEncryptionKey | quote }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,56 @@
|
||||
{{- if .Values.backup.enabled }}
|
||||
{{- if not .Values.backup.pvcName }}
|
||||
{{- fail "backup.enabled requires backup.pvcName: a backup needs somewhere durable to land, and the chart cannot guess where that is" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.backup.image }}
|
||||
{{- fail "backup.enabled requires backup.image: the vantagectl image to run" }}
|
||||
{{- end }}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "vantage.fullname" . }}-backup
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backup
|
||||
spec:
|
||||
schedule: {{ .Values.backup.schedule | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }}
|
||||
failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }}
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 12 }}
|
||||
app.kubernetes.io/component: backup
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: vantagectl
|
||||
image: {{ .Values.backup.image | quote }}
|
||||
args:
|
||||
- backup
|
||||
- --out
|
||||
- /backups
|
||||
{{- with .Values.backup.exclude }}
|
||||
- --exclude
|
||||
- {{ join "," . | quote }}
|
||||
{{- end }}
|
||||
env:
|
||||
# Referenced, never redeclared. A backup job holding its own
|
||||
# copy of KEY_ENCRYPTION_KEY is a second place for it to be
|
||||
# wrong, and the fingerprint it stamps would then be a
|
||||
# fingerprint of the wrong key.
|
||||
{{- include "vantage.backup.env" . | nindent 16 }}
|
||||
volumeMounts:
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
resources:
|
||||
{{- toYaml .Values.backup.resources | nindent 16 }}
|
||||
volumes:
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.backup.pvcName | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,47 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-guacd
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: guacd
|
||||
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
|
||||
ports:
|
||||
- containerPort: 4822
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-guacd
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
type: {{ .Values.guacd.service.type }}
|
||||
{{- if eq .Values.guacd.service.type "ClusterIP" }}
|
||||
clusterIP: None
|
||||
{{- end }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
ports:
|
||||
- port: {{ .Values.guacd.service.port }}
|
||||
targetPort: 4822
|
||||
@@ -0,0 +1,174 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{/*
|
||||
Two hostnames, because the two audiences arrive over different protocols.
|
||||
|
||||
Browsers reach the web host, and the path decides what answers: /api, /auth,
|
||||
/public, /install* and /update* go to the server, everything else to `web`.
|
||||
|
||||
That split is not optional and ingress.api.enabled defaults to true. `web`
|
||||
proxies nothing — it holds no address for the server at all — so with these
|
||||
paths absent the UI loads and every request it makes 404s against Next. The
|
||||
setting remains a value only so an installation terminating in front of this
|
||||
ingress can route the prefixes itself; it must be routed somewhere.
|
||||
|
||||
The web host is normally a wildcard — `*.vantage.example.com` — because that is
|
||||
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
|
||||
label. Kubernetes wildcard hosts match exactly one label, so this does not match
|
||||
the apex, and on the deployment this chart was written for it must not: the apex
|
||||
is the marketing site, a separate application in the vantage-site repository.
|
||||
extraHosts exists for a genuine second name, not for
|
||||
reclaiming the apex.
|
||||
|
||||
Agents reach the server's gRPC port, which is plain h2c — the server holds no
|
||||
certificates of its own, TLS has always been terminated by whatever sits in
|
||||
front. Traefik will not speak h2c to a backend unless told to, and it is told
|
||||
per Service, which is why the gRPC route gets a Service of its own below rather
|
||||
than reusing the two-port one. Annotating the shared Service would force h2c on
|
||||
its HTTP port too.
|
||||
*/}}
|
||||
{{- $tls := .Values.ingress.tls }}
|
||||
{{- $webHost := required "ingress.enabled requires ingress.web.host" .Values.ingress.web.host }}
|
||||
{{- $hosts := prepend .Values.ingress.web.extraHosts $webHost }}
|
||||
{{- $apiPaths := .Values.ingress.api.paths }}
|
||||
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
|
||||
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.ingress.api.enabled }}
|
||||
{{- fail "ingress.api.enabled=false leaves /api, /auth and /public unrouted: web proxies nothing. Route those prefixes to the server at your own terminator, or leave this enabled." }}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: {{ .Values.ingress.entrypoint | quote }}
|
||||
{{- if and $tls.enabled $tls.certResolver }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ $tls.certResolver | quote }}
|
||||
{{- else if $tls.enabled }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if and $tls.enabled $tls.secretName }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- range $hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ $tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range $host := $hosts }}
|
||||
- host: {{ $host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- /*
|
||||
The API paths come first and, more importantly, are longer. Traefik
|
||||
derives router priority from rule length, so Host(x) &&
|
||||
PathPrefix(/api) outranks Host(x) && PathPrefix(/) without anyone
|
||||
having to assign priorities by hand. Order within the list is for
|
||||
the reader; the length is what decides.
|
||||
*/}}
|
||||
{{- if $.Values.ingress.api.enabled }}
|
||||
{{- range $apiPaths }}
|
||||
- path: {{ . | quote }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ $.Release.Name }}-server
|
||||
port:
|
||||
number: {{ $.Values.server.service.httpPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ $.Release.Name }}-web
|
||||
port:
|
||||
number: {{ $.Values.web.service.port }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.grpc.enabled }}
|
||||
{{- $grpcHost := required "ingress.grpc.enabled requires ingress.grpc.host" .Values.ingress.grpc.host }}
|
||||
{{/*
|
||||
GRPC_HOST is what an agent is told to dial, and it is baked into every install
|
||||
one-liner. Left pointing at the in-cluster Service while agents are expected to
|
||||
arrive through the ingress, every install would succeed and every agent would
|
||||
fail to connect — with nothing in the control plane saying why.
|
||||
*/}}
|
||||
{{- $grpcEnv := tpl .Values.server.env.grpcHost . }}
|
||||
{{- if contains (printf "%s-server" .Release.Name) $grpcEnv }}
|
||||
{{- fail (printf "ingress.grpc.enabled routes agents through %s, but server.env.grpcHost is still the in-cluster address %q. Agents dial the value of grpcHost, so set it to the public gRPC address (for example %q)." $grpcHost $grpcEnv (printf "%s:443" $grpcHost)) }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server-grpc
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
annotations:
|
||||
# The server speaks h2c: it terminates no TLS itself. Without this Traefik
|
||||
# dials the backend as HTTP/1.1 and every agent handshake fails.
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: h2c
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
ports:
|
||||
- name: grpc
|
||||
port: {{ .Values.server.service.grpcPort }}
|
||||
targetPort: {{ .Values.server.service.grpcPort }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-grpc
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: {{ .Values.ingress.entrypoint | quote }}
|
||||
{{- if and $tls.enabled $tls.certResolver }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ $tls.certResolver | quote }}
|
||||
{{- else if $tls.enabled }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.grpc.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if and $tls.enabled $tls.grpcSecretName }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ $grpcHost | quote }}
|
||||
secretName: {{ $tls.grpcSecretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ $grpcHost | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-server-grpc
|
||||
port:
|
||||
number: {{ .Values.server.service.grpcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
{{/*
|
||||
Schema setup, lifted out of the serving pods.
|
||||
|
||||
Every server process used to run migrations, index builders and default-step
|
||||
seeding at boot. With one replica that is fine. With two it is not: 0004 renames
|
||||
the orgs collection to instances, and a sibling reading it mid-rename is a
|
||||
corruption, not a retry.
|
||||
|
||||
A Helm hook Job runs it once, before any pod of the new version starts. The
|
||||
Deployment then sets VANTAGE_SKIP_MIGRATIONS, which is what makes the Job's
|
||||
existence load-bearing rather than decorative — if you disable the Job, the
|
||||
pods go back to migrating themselves and you must go back to one replica.
|
||||
|
||||
hook-weight orders this after the dependency waits; before-hook-creation deletes
|
||||
the previous Job so a repeat upgrade is not blocked by an immutable object. The
|
||||
Job is deliberately NOT deleted on success: its logs are the record of what the
|
||||
upgrade did to the database.
|
||||
*/}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-migrate
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "0"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation
|
||||
spec:
|
||||
backoffLimit: {{ .Values.server.migrationJob.backoffLimit }}
|
||||
# A migration that has not finished in this long is stuck, and a stuck
|
||||
# migration should fail the upgrade rather than hold it open forever.
|
||||
activeDeadlineSeconds: {{ .Values.server.migrationJob.activeDeadlineSeconds }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
# Only Mongo. The Job never opens Redis, and waiting on a Redis this
|
||||
# chart may not even deploy would block an upgrade for no reason.
|
||||
initContainers:
|
||||
- name: wait-for-mongo
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
|
||||
echo "waiting for mongo..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migrate
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
env:
|
||||
{{- include "vantage.server.env" . | nindent 12 }}
|
||||
- name: VANTAGE_MIGRATE_ONLY
|
||||
value: "true"
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.mongo.enabled }}
|
||||
{{- if .Values.mongo.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.mongo.persistence.accessMode }}
|
||||
{{- if .Values.mongo.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.mongo.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.mongo.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: mongo
|
||||
image: "{{ .Values.mongo.image.repository }}:{{ .Values.mongo.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.mongo.port }}
|
||||
volumeMounts:
|
||||
- name: mongo-data
|
||||
mountPath: /data/db
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: mongo-data
|
||||
{{- if .Values.mongo.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-mongo-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
ports:
|
||||
- port: {{ .Values.mongo.port }}
|
||||
targetPort: {{ .Values.mongo.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
{{- if .Values.redis.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.redis.persistence.accessMode }}
|
||||
{{- if .Values.redis.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.redis.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.redis.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: redis
|
||||
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.redis.port }}
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: redis-data
|
||||
{{- if .Values.redis.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-redis-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
ports:
|
||||
- port: {{ .Values.redis.port }}
|
||||
targetPort: {{ .Values.redis.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,181 @@
|
||||
{{- if and .Values.server.persistence.enabled (not .Values.server.persistence.useHostPath) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.server.persistence.accessMode }}
|
||||
{{- if .Values.server.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.server.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.server.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
{{- $replicas := int .Values.server.replicaCount }}
|
||||
replicas: {{ $replicas }}
|
||||
{{- if and .Values.server.persistence.enabled (eq .Values.server.persistence.accessMode "ReadWriteOnce") }}
|
||||
# A ReadWriteOnce volume cannot be mounted by a second pod at all, and cannot
|
||||
# be handed to a new pod while the old one still holds it. Persistence is off
|
||||
# by default now that nothing writes to it; if it is on, replicas are capped
|
||||
# at one and updates go through Recreate.
|
||||
{{- if gt $replicas 1 }}
|
||||
{{- fail "server.persistence.enabled with a ReadWriteOnce volume cannot be combined with server.replicaCount > 1. Nothing in the server writes to that volume any more (workflow logs live in MongoDB); set server.persistence.enabled=false, or use a ReadWriteMany accessMode if you are keeping it for another reason." }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
# The server stops gRPC before draining HTTP, so that every CommandStream
|
||||
# handler returns and releases its agent's presence claim. A claim left
|
||||
# behind outlives the pod for its 30s TTL, and during that window other
|
||||
# replicas dispatch commands to a process that has exited — surfacing to
|
||||
# the operator as "agent offline" on an agent that is perfectly healthy.
|
||||
# 10s for gRPC plus 10s for the HTTP drain, with headroom.
|
||||
terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }}
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.redis.enabled .Values.mongo.enabled }}
|
||||
# Wait for the dependencies this chart deploys to be reachable,
|
||||
# approximating compose's `depends_on: condition: service_healthy`. An
|
||||
# external Redis or Mongo is assumed to be up already — waiting on one
|
||||
# would only turn someone else's outage into a stuck pod.
|
||||
initContainers:
|
||||
{{- if .Values.redis.enabled }}
|
||||
- name: wait-for-redis
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-redis {{ .Values.redis.port }}; do
|
||||
echo "waiting for redis..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
- name: wait-for-mongo
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
|
||||
echo "waiting for mongo..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.service.httpPort }}
|
||||
- containerPort: {{ .Values.server.service.grpcPort }}
|
||||
env:
|
||||
{{- include "vantage.server.env" . | nindent 12 }}
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
# Schema setup ran in the pre-upgrade Job. Pods that repeated it
|
||||
# would race each other, and the rename migration is not a race
|
||||
# that tolerates a loser.
|
||||
- name: VANTAGE_SKIP_MIGRATIONS
|
||||
value: "true"
|
||||
{{- end }}
|
||||
# Liveness never touches Mongo or Redis: restarting every pod cannot
|
||||
# fix a database outage, and each restart drops every agent command
|
||||
# stream and console session it was carrying. Readiness does check
|
||||
# both, so a pod that cannot serve leaves the Service and stays up.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 5
|
||||
# Generous: without the migration Job this pod runs every migration
|
||||
# before it listens, and the rename has a ten-minute budget.
|
||||
failureThreshold: 150
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
{{- if .Values.server.persistence.enabled }}
|
||||
# Nothing in the server writes here any more — workflow logs moved to
|
||||
# MongoDB so that every replica can read and write them. The mount
|
||||
# remains only so an operator upgrading from a file-log release can
|
||||
# still reach the old files before turning persistence off.
|
||||
volumeMounts:
|
||||
- name: server-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: server-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-server-data
|
||||
{{- end }}
|
||||
---
|
||||
{{- if gt (int .Values.server.replicaCount) 1 }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
# Agents reconnect on their own, but a drain that took every replica at once
|
||||
# would disconnect every agent in the fleet simultaneously and stall every
|
||||
# workflow run in flight.
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
type: {{ .Values.server.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
targetPort: {{ .Values.server.service.httpPort }}
|
||||
- name: grpc
|
||||
port: {{ .Values.server.service.grpcPort }}
|
||||
targetPort: {{ .Values.server.service.grpcPort }}
|
||||
@@ -0,0 +1,78 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
# web holds no per-process state: sessions live in Redis and every request is
|
||||
# proxied to the server. It is the one component here that scales freely.
|
||||
replicas: {{ .Values.web.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
initContainers:
|
||||
- name: wait-for-server
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-server {{ .Values.server.service.httpPort }}; do
|
||||
echo "waiting for server..."; sleep 2;
|
||||
done
|
||||
containers:
|
||||
- name: web
|
||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
# /healthz is served by this Next process. /api never reaches this
|
||||
# pod at all — the ingress routes it to the server — so there is no
|
||||
# backend address to configure and no probe here that could report
|
||||
# the backend's health by accident.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 3
|
||||
failureThreshold: 20
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: {{ .Values.web.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
ports:
|
||||
- port: {{ .Values.web.service.port }}
|
||||
targetPort: {{ .Values.web.service.port }}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Default values for the vantage chart.
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
addr: ""
|
||||
image:
|
||||
repository: redis
|
||||
tag: "8"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 6379
|
||||
auth:
|
||||
username: ""
|
||||
password: ""
|
||||
existingSecret: ""
|
||||
usernameKey: username
|
||||
passwordKey: password
|
||||
|
||||
mongo:
|
||||
enabled: true
|
||||
image:
|
||||
repository: mongo
|
||||
tag: "7"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 27017
|
||||
|
||||
guacd:
|
||||
image:
|
||||
repository: docker.io/guacamole/guacd
|
||||
tag: "1.6.0"
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 4822
|
||||
|
||||
server:
|
||||
replicaCount: 1
|
||||
# Must exceed the server's own stop sequence (10s gRPC GracefulStop + 10s
|
||||
# HTTP drain) or the kubelet SIGKILLs mid-shutdown, which is exactly the
|
||||
# abrupt exit that leaves agent presence claims stranded in Redis.
|
||||
terminationGracePeriodSeconds: 30
|
||||
migrationJob:
|
||||
enabled: true
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 900
|
||||
image:
|
||||
repository: gitea.hostxtra.co.uk/mrhid6/vantage/server
|
||||
tag: latest
|
||||
service:
|
||||
type: ClusterIP
|
||||
httpPort: 8080
|
||||
grpcPort: 9090
|
||||
env:
|
||||
mongoUri: "mongodb://{{ .Release.Name }}-mongo:27017/vantage"
|
||||
grpcHost: "{{ .Release.Name }}-server:9090"
|
||||
keyEncryptionKey: ""
|
||||
appRootLabel: vantage
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
hostPath: /data
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: gitea.hostxtra.co.uk/mrhid6/vantage/web
|
||||
tag: latest
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: traefik
|
||||
entrypoint: websecure
|
||||
annotations: {}
|
||||
web:
|
||||
host: ""
|
||||
extraHosts: []
|
||||
# Not optional: web proxies nothing, so these prefixes reach the server
|
||||
# only through this ingress. Turning it off serves the UI with a dead API.
|
||||
api:
|
||||
enabled: true
|
||||
paths:
|
||||
- /api/
|
||||
- /auth/
|
||||
- /public/
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
- /install.ps1
|
||||
grpc:
|
||||
enabled: true
|
||||
host: ""
|
||||
annotations: {}
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: ""
|
||||
grpcSecretName: ""
|
||||
certResolver: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
# Scheduled backups.
|
||||
#
|
||||
# Off by default, deliberately. A backup with nowhere durable to land is a
|
||||
# false sense of safety, and the chart cannot know where that is — pvcName
|
||||
# must name a volume you have decided will outlive the cluster.
|
||||
#
|
||||
# There is no restore manifest here on purpose: a restore is an operator
|
||||
# decision with a confirmation attached, and must never be something a
|
||||
# `helm upgrade` can trigger. Run one as a `kubectl run` Job with
|
||||
# --confirm-db.
|
||||
backup:
|
||||
enabled: false
|
||||
schedule: "0 2 * * *"
|
||||
image: ""
|
||||
pvcName: ""
|
||||
# Collections to leave out. Recorded in each archive's manifest, so an
|
||||
# archive can never claim to be complete when it is not.
|
||||
exclude: []
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
resources: {}
|
||||
@@ -1,17 +0,0 @@
|
||||
services:
|
||||
migrate:
|
||||
image: mongo:8
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./migrate/server-migrate.sh:/migrate.sh:ro
|
||||
command: bash /migrate.sh
|
||||
environment:
|
||||
MONGO_HOST: mongo
|
||||
MONGO_PORT: "27017"
|
||||
SRC_DB: keymanager
|
||||
DST_DB: vantage
|
||||
# Set DROP_SRC=true to automatically drop the keymanager database after migration
|
||||
DROP_SRC: "false"
|
||||
restart: "no"
|
||||
@@ -1,65 +0,0 @@
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
redis:
|
||||
image: redis:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "9090:9090"
|
||||
environment:
|
||||
MONGO_URI: mongodb://mongo:27017/vantage
|
||||
REDIS_ADDR: redis:6379
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||
GRPC_HOST: ${GRPC_HOST}
|
||||
GRPC_PORT: "9090"
|
||||
HTTP_PORT: "8080"
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ../web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://server:8080
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,20 @@
|
||||
# Vantage self-hosted — copy to .env and fill in.
|
||||
# Used by: docker compose up -d
|
||||
|
||||
# --- Required ---
|
||||
|
||||
# host:port agents dial for gRPC. No default; boot fails without it.
|
||||
# Must be reachable from managed servers. Use the public host, port 9090.
|
||||
GRPC_HOST=vantage.yourdomain.com:9090
|
||||
|
||||
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
|
||||
# secrets, OIDC secrets, RDP/VNC credentials.
|
||||
# Generate: openssl rand -hex 32
|
||||
KEY_ENCRYPTION_KEY=
|
||||
|
||||
# --- Optional (defaults shown) ---
|
||||
|
||||
# MongoDB is bundled in this compose file. Override only to use an external DB.
|
||||
MONGO_URI=mongodb://mongo:27017/vantage
|
||||
|
||||
# Where workflow run logs are written inside the server container.
|
||||
@@ -0,0 +1,72 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
mongo:
|
||||
image: mongo:7
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mongosh
|
||||
- --quiet
|
||||
- --eval
|
||||
- "db.adminCommand('ping')"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
guacd:
|
||||
image: docker.io/guacamole/guacd:1.6.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 4822:4822
|
||||
server:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 9090:9090
|
||||
environment:
|
||||
MONGO_URI: ${MONGO_URI:-mongodb://mongo:27017/vantage}
|
||||
REDIS_ADDR: redis:6379
|
||||
GRPC_HOST: ${GRPC_HOST}
|
||||
GRPC_PORT: "9090"
|
||||
HTTP_PORT: "8080"
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./data:/data
|
||||
web:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
# No API_URL: web proxies nothing. The reverse proxy in front of this
|
||||
# deployment must route /api, /auth, /public, /install*, /update* to
|
||||
# server:8080 and everything else to web:3000. Reaching web:3000
|
||||
# directly serves the UI and every API call 404s.
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
mongo_data: null
|
||||
redis_data: null
|
||||
networks: {}
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Migrates an existing keymanager-agent installation to vantage-agent.
|
||||
# Run as root on each managed server.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
|
||||
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Must be run as root"
|
||||
|
||||
GITEA_HOST="${GITEA_HOST:-}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Detect old installation
|
||||
# ---------------------------------------------------------------------------
|
||||
OLD_BINARY="/usr/local/bin/keymanager-agent"
|
||||
OLD_CONFIG_DIR="/etc/keymanager"
|
||||
OLD_CONFIG="$OLD_CONFIG_DIR/config.yaml"
|
||||
OLD_SERVICE="keymanager-agent"
|
||||
OLD_SERVICE_FILE="/etc/systemd/system/${OLD_SERVICE}.service"
|
||||
OLD_SSH_CONF="/root/.ssh/keymanager.conf"
|
||||
OLD_SSH_CONFIG="/root/.ssh/config"
|
||||
|
||||
NEW_BINARY="/usr/local/bin/vantage-agent"
|
||||
NEW_CONFIG_DIR="/etc/vantage"
|
||||
NEW_CONFIG="$NEW_CONFIG_DIR/config.yaml"
|
||||
NEW_SERVICE="vantage-agent"
|
||||
NEW_SERVICE_FILE="/etc/systemd/system/${NEW_SERVICE}.service"
|
||||
NEW_SSH_CONF="/root/.ssh/vantage.conf"
|
||||
|
||||
if [ ! -f "$OLD_CONFIG" ] && [ ! -f "$OLD_BINARY" ]; then
|
||||
warn "No keymanager-agent installation found — nothing to migrate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Found keymanager-agent installation. Starting migration to vantage-agent..."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Stop and disable old service
|
||||
# ---------------------------------------------------------------------------
|
||||
if systemctl is-active --quiet "$OLD_SERVICE" 2>/dev/null; then
|
||||
info "Stopping $OLD_SERVICE..."
|
||||
systemctl stop "$OLD_SERVICE"
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$OLD_SERVICE" 2>/dev/null; then
|
||||
systemctl disable "$OLD_SERVICE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Migrate config directory
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OLD_CONFIG" ] && [ ! -f "$NEW_CONFIG" ]; then
|
||||
info "Migrating config: $OLD_CONFIG -> $NEW_CONFIG"
|
||||
mkdir -p "$NEW_CONFIG_DIR"
|
||||
chmod 0700 "$NEW_CONFIG_DIR"
|
||||
cp "$OLD_CONFIG" "$NEW_CONFIG"
|
||||
chmod 0600 "$NEW_CONFIG"
|
||||
elif [ -f "$NEW_CONFIG" ]; then
|
||||
warn "$NEW_CONFIG already exists — skipping config copy."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Migrate SSH managed conf file
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OLD_SSH_CONF" ]; then
|
||||
info "Migrating SSH conf: $OLD_SSH_CONF -> $NEW_SSH_CONF"
|
||||
|
||||
# Rewrite IdentityFile paths: /root/.ssh/keymanager_* -> /root/.ssh/vantage_*
|
||||
sed 's|/root/\.ssh/keymanager_|/root/.ssh/vantage_|g' "$OLD_SSH_CONF" > "$NEW_SSH_CONF"
|
||||
chmod 0600 "$NEW_SSH_CONF"
|
||||
fi
|
||||
|
||||
# Update Include directive in /root/.ssh/config
|
||||
if [ -f "$OLD_SSH_CONFIG" ]; then
|
||||
if grep -q "Include /root/.ssh/keymanager.conf" "$OLD_SSH_CONFIG"; then
|
||||
info "Updating Include directive in $OLD_SSH_CONFIG"
|
||||
sed -i 's|Include /root/\.ssh/keymanager\.conf|Include /root/.ssh/vantage.conf|g' "$OLD_SSH_CONFIG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Rename generated key files
|
||||
# ---------------------------------------------------------------------------
|
||||
shopt -s nullglob
|
||||
OLD_KEYS=(/root/.ssh/keymanager_*)
|
||||
if [ ${#OLD_KEYS[@]} -gt 0 ]; then
|
||||
info "Renaming ${#OLD_KEYS[@]} key file(s)..."
|
||||
for old_path in "${OLD_KEYS[@]}"; do
|
||||
filename=$(basename "$old_path")
|
||||
new_filename="${filename/keymanager_/vantage_}"
|
||||
new_path="/root/.ssh/$new_filename"
|
||||
if [ ! -e "$new_path" ]; then
|
||||
cp "$old_path" "$new_path"
|
||||
chmod "$(stat -c '%a' "$old_path")" "$new_path"
|
||||
info " $old_path -> $new_path"
|
||||
else
|
||||
warn " $new_path already exists — skipping"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
shopt -u nullglob
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Download new vantage-agent binary
|
||||
# ---------------------------------------------------------------------------
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*) die "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$GITEA_HOST" ] && [ -n "$GITEA_OWNER" ]; then
|
||||
info "Fetching latest vantage-agent release from $GITEA_HOST..."
|
||||
|
||||
RELEASE_JSON=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/vantage/releases?limit=1&type=tag" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$RELEASE_JSON" ]; then
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*vantage-agent-linux-${ARCH}\"" | head -1 | cut -d'"' -f4)
|
||||
CHECKSUM_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*checksums\.txt\"" | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$DOWNLOAD_URL" ]; then
|
||||
info "Downloading $DOWNLOAD_URL..."
|
||||
TMP_BIN="/tmp/vantage-agent-new"
|
||||
curl -fsSL -o "$TMP_BIN" "$DOWNLOAD_URL"
|
||||
|
||||
if [ -n "$CHECKSUM_URL" ]; then
|
||||
TMP_SUMS="/tmp/vantage-checksums.txt"
|
||||
curl -fsSL -o "$TMP_SUMS" "$CHECKSUM_URL"
|
||||
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" "$TMP_SUMS" | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum "$TMP_BIN" | awk '{print $1}')
|
||||
[ "$EXPECTED" = "$ACTUAL" ] || die "Checksum mismatch! Expected $EXPECTED, got $ACTUAL"
|
||||
rm -f "$TMP_SUMS"
|
||||
info "Checksum verified."
|
||||
fi
|
||||
|
||||
chmod 0755 "$TMP_BIN"
|
||||
mv "$TMP_BIN" "$NEW_BINARY"
|
||||
info "Installed $NEW_BINARY"
|
||||
else
|
||||
warn "Could not find vantage-agent binary in release — skipping binary install."
|
||||
fi
|
||||
else
|
||||
warn "Could not reach Gitea API — skipping binary download."
|
||||
fi
|
||||
elif [ -f "$OLD_BINARY" ]; then
|
||||
warn "GITEA_HOST/GITEA_OWNER not set — skipping binary download."
|
||||
warn "You must manually install the vantage-agent binary to $NEW_BINARY before starting the service."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Install new systemd service
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing $NEW_SERVICE_FILE..."
|
||||
cat > "$NEW_SERVICE_FILE" <<'EOF'
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=vantage-agent
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=false
|
||||
ProtectHome=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$NEW_SERVICE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Start new service (only if binary exists)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$NEW_BINARY" ]; then
|
||||
info "Starting $NEW_SERVICE..."
|
||||
systemctl start "$NEW_SERVICE"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$NEW_SERVICE"; then
|
||||
info "vantage-agent is running."
|
||||
else
|
||||
warn "vantage-agent failed to start. Check: journalctl -u vantage-agent"
|
||||
fi
|
||||
else
|
||||
warn "Binary not yet installed — service NOT started."
|
||||
warn "Install the binary then run: systemctl start vantage-agent"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Clean up old installation
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Cleaning up old keymanager-agent files..."
|
||||
rm -f "$OLD_SERVICE_FILE"
|
||||
rm -f "$OLD_BINARY"
|
||||
rm -rf "$OLD_CONFIG_DIR"
|
||||
rm -f "$OLD_SSH_CONF"
|
||||
|
||||
shopt -s nullglob
|
||||
for old_key in /root/.ssh/keymanager_*; do
|
||||
rm -f "$old_key"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
systemctl daemon-reload
|
||||
info "Migration complete."
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs inside the migration container.
|
||||
# Copies all collections + indexes from $SRC_DB to $DST_DB,
|
||||
# verifies document counts, then optionally drops the source.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
|
||||
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
|
||||
|
||||
MONGO_HOST="${MONGO_HOST:-mongo}"
|
||||
MONGO_PORT="${MONGO_PORT:-27017}"
|
||||
SRC_DB="${SRC_DB:-keymanager}"
|
||||
DST_DB="${DST_DB:-vantage}"
|
||||
DROP_SRC="${DROP_SRC:-false}"
|
||||
|
||||
MONGO_URI="mongodb://${MONGO_HOST}:${MONGO_PORT}"
|
||||
|
||||
mongosh_eval() {
|
||||
local db="$1"; local script="$2"
|
||||
mongosh --quiet "${MONGO_URI}/${db}" --eval "$script"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Wait for MongoDB to be reachable
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Waiting for MongoDB at ${MONGO_HOST}:${MONGO_PORT}..."
|
||||
for i in $(seq 1 30); do
|
||||
mongosh --quiet "${MONGO_URI}/admin" --eval "db.adminCommand('ping')" >/dev/null 2>&1 && break
|
||||
[ "$i" -eq 30 ] && die "MongoDB not reachable after 30 attempts."
|
||||
sleep 2
|
||||
done
|
||||
info "MongoDB is ready."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Check source database
|
||||
# ---------------------------------------------------------------------------
|
||||
SRC_COLLECTIONS=$(mongosh_eval admin "
|
||||
const names = db.getSiblingDB('${SRC_DB}').getCollectionNames();
|
||||
print(names.join(','));
|
||||
")
|
||||
|
||||
if [ -z "$SRC_COLLECTIONS" ] || [ "$SRC_COLLECTIONS" = "," ]; then
|
||||
warn "Source database '${SRC_DB}' has no collections — nothing to migrate."
|
||||
warn "If this is a fresh deployment, '${DST_DB}' will be created automatically."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Collections in '${SRC_DB}': ${SRC_COLLECTIONS}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Copy all collections via \$out
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Copying collections from '${SRC_DB}' to '${DST_DB}'..."
|
||||
|
||||
mongosh_eval admin "
|
||||
const src = db.getSiblingDB('${SRC_DB}');
|
||||
const cols = src.getCollectionNames();
|
||||
cols.forEach(function(name) {
|
||||
src[name].aggregate([{ \\\$out: { db: '${DST_DB}', coll: name } }]);
|
||||
print('Copied: ' + name);
|
||||
});
|
||||
"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Recreate indexes
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Recreating indexes in '${DST_DB}'..."
|
||||
|
||||
mongosh_eval admin "
|
||||
const src = db.getSiblingDB('${SRC_DB}');
|
||||
const dst = db.getSiblingDB('${DST_DB}');
|
||||
src.getCollectionNames().forEach(function(col) {
|
||||
src[col].getIndexes().forEach(function(idx) {
|
||||
if (idx.name === '_id_') return;
|
||||
const opts = { name: idx.name };
|
||||
if (idx.unique) opts.unique = true;
|
||||
if (idx.sparse) opts.sparse = true;
|
||||
if (idx.expireAfterSeconds !== undefined) opts.expireAfterSeconds = idx.expireAfterSeconds;
|
||||
try {
|
||||
dst[col].createIndex(idx.key, opts);
|
||||
print('Index: ' + col + '.' + idx.name);
|
||||
} catch(e) {
|
||||
print('Skipped index ' + idx.name + ' on ' + col + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Verify document counts
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Verifying document counts..."
|
||||
|
||||
MISMATCH=0
|
||||
IFS=',' read -ra COLS <<< "$SRC_COLLECTIONS"
|
||||
for col in "${COLS[@]}"; do
|
||||
[ -z "$col" ] && continue
|
||||
SRC_N=$(mongosh_eval "$SRC_DB" "print(db['${col}'].countDocuments())")
|
||||
DST_N=$(mongosh_eval "$DST_DB" "print(db['${col}'].countDocuments())")
|
||||
if [ "$SRC_N" = "$DST_N" ]; then
|
||||
info " ${col}: ${SRC_N} docs OK"
|
||||
else
|
||||
warn " ${col}: src=${SRC_N} dst=${DST_N} MISMATCH"
|
||||
MISMATCH=1
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$MISMATCH" -eq 1 ] && die "Count mismatch — source database NOT dropped. Investigate and re-run."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Optionally drop source database
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$DROP_SRC" = "true" ]; then
|
||||
info "Dropping source database '${SRC_DB}'..."
|
||||
mongosh_eval admin "db.getSiblingDB('${SRC_DB}').dropDatabase(); print('Dropped.');"
|
||||
info "Dropped '${SRC_DB}'."
|
||||
else
|
||||
warn "Source database '${SRC_DB}' kept. Set DROP_SRC=true to drop it automatically."
|
||||
fi
|
||||
|
||||
info "Migration complete."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
<title>Vantage Status Pages</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme: dark;
|
||||
/* Vantage web/ dark tokens, copied verbatim from web/app/globals.css.
|
||||
This mockup commits to one theme because web/ does. */
|
||||
--ground:#071628; --panel:#0d2138; --panel-2:#102842; --well:#04101f;
|
||||
--ink:#e4ecf6; --ink-2:#9fb3ca; --ink-3:#71879f;
|
||||
--rule:#1e3855; --rule-soft:#172c44;
|
||||
--accent:#5b9be8; --accent-hover:#7fb2f0; --accent-ink:#04101f;
|
||||
--up:#4fb484; --pend:#d6a63f; --down:#e2705a; --logo:#7fb2f0;
|
||||
--shadow:0 1px 0 rgba(0,0,0,.35), 0 20px 44px -26px rgba(0,0,0,.85);
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--r:4px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--ground);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;line-height:1.5}
|
||||
a{color:inherit}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
|
||||
.page{max-width:1180px;margin:0 auto;padding:48px 24px 96px;display:flex;flex-direction:column;gap:56px}
|
||||
|
||||
.lede h1{font-size:1.6rem;font-weight:800;letter-spacing:-.035em;text-wrap:balance}
|
||||
.lede p{color:var(--ink-2);font-size:.9rem;max-width:65ch;margin-top:8px}
|
||||
|
||||
.cap{font-family:var(--mono);font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
|
||||
|
||||
.board{display:flex;flex-direction:column;gap:10px}
|
||||
.board__head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
.board__route{font-family:var(--mono);font-size:.7rem;color:var(--ink-3)}
|
||||
.frame{border:1px solid var(--rule);border-radius:var(--r);background:var(--ground);box-shadow:var(--shadow);overflow:hidden}
|
||||
|
||||
/* address strip — shows the URL scheme being approved */
|
||||
.addr{display:flex;align-items:center;gap:10px;background:var(--well);border-bottom:1px solid var(--rule);padding:9px 14px}
|
||||
.addr__dots{display:flex;gap:5px}
|
||||
.addr__dots i{width:8px;height:8px;border-radius:999px;background:var(--rule);display:block}
|
||||
.addr__url{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);overflow-x:auto;white-space:nowrap}
|
||||
.addr__url b{color:var(--ink);font-weight:600}
|
||||
.addr__tag{margin-left:auto;font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);border:1px solid var(--rule);border-radius:999px;padding:2px 8px;white-space:nowrap}
|
||||
|
||||
/* ---------- public status page ---------- */
|
||||
.pub{padding:40px 28px 32px}
|
||||
.pub__inner{max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:28px}
|
||||
.pub__head{display:flex;align-items:center;gap:14px}
|
||||
.mark{width:38px;height:38px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--rule);display:grid;place-items:center;color:var(--logo);font-family:var(--mono);font-weight:700;font-size:.85rem;flex-shrink:0}
|
||||
.pub__head h2{font-size:1.35rem;font-weight:800;letter-spacing:-.03em}
|
||||
.pub__head p{color:var(--ink-2);font-size:.85rem;margin-top:2px}
|
||||
|
||||
.overall{display:flex;align-items:center;gap:11px;border:1px solid;border-radius:var(--r);padding:14px 16px;font-weight:600;font-size:.95rem}
|
||||
.overall--down{background:rgba(226,112,90,.10);border-color:rgba(226,112,90,.30);color:var(--down)}
|
||||
.glyph{width:16px;height:16px;flex-shrink:0}
|
||||
|
||||
.banner{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r);padding:12px 14px;font-size:.85rem;color:var(--ink-2);display:flex;gap:10px}
|
||||
.banner b{color:var(--ink);font-weight:600}
|
||||
|
||||
.group{display:flex;flex-direction:column;gap:10px}
|
||||
.group > .cap{padding-left:2px}
|
||||
|
||||
.card{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
|
||||
.rows > * + *{border-top:1px solid var(--rule-soft)}
|
||||
|
||||
.comp{padding:16px}
|
||||
.comp__top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:10px}
|
||||
.comp__name{font-weight:600;font-size:.92rem}
|
||||
.state{display:inline-flex;align-items:center;gap:7px;font-size:.78rem;color:var(--ink-2);white-space:nowrap}
|
||||
.dot{width:7px;height:7px;border-radius:999px;display:block;flex-shrink:0}
|
||||
.dot--up{background:var(--up)} .dot--down{background:var(--down)}
|
||||
.dot--maint{background:var(--accent)} .dot--pend{background:var(--pend)}
|
||||
.dot--none{background:var(--rule)}
|
||||
|
||||
.bar{display:flex;gap:2px;overflow-x:auto;padding-bottom:2px}
|
||||
.bar span{height:26px;width:3px;border-radius:999px;flex:0 0 auto;background:var(--rule)}
|
||||
.bar .up{background:var(--up)} .bar .down{background:var(--down)}
|
||||
.bar .maint{background:var(--accent)} .bar .none{background:var(--rule)}
|
||||
.scale{display:flex;justify-content:space-between;margin-top:7px;font-size:.7rem;color:var(--ink-3)}
|
||||
.scale b{color:var(--ink-2);font-weight:600;font-variant-numeric:tabular-nums}
|
||||
|
||||
.inc{padding:14px 16px}
|
||||
.inc__top{display:flex;align-items:baseline;justify-content:space-between;gap:14px}
|
||||
.inc__title{font-weight:600;font-size:.92rem}
|
||||
.inc__meta{font-size:.75rem;color:var(--ink-3);margin-top:3px}
|
||||
.inc__affects{font-size:.75rem;color:var(--ink-2);margin-top:5px}
|
||||
.pill{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;border-radius:999px;padding:3px 9px;border:1px solid;white-space:nowrap}
|
||||
.pill--inv{color:var(--down);border-color:rgba(226,112,90,.35);background:rgba(226,112,90,.10)}
|
||||
.pill--mon{color:var(--pend);border-color:rgba(214,166,63,.35);background:rgba(214,166,63,.10)}
|
||||
.pill--res{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
|
||||
.pill--sch{color:var(--accent);border-color:rgba(91,155,232,.35);background:rgba(91,155,232,.10)}
|
||||
.pill--draft{color:var(--ink-2);border-color:var(--rule);background:var(--panel-2)}
|
||||
.pill--live{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
|
||||
|
||||
.timeline{margin-top:12px;border-left:1px solid var(--rule);padding-left:14px;display:flex;flex-direction:column;gap:12px}
|
||||
.tl__head{display:flex;align-items:baseline;gap:9px}
|
||||
.tl__st{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
|
||||
.tl__at{font-size:.7rem;color:var(--ink-3);font-variant-numeric:tabular-nums}
|
||||
.tl__body{font-size:.85rem;margin-top:3px;color:var(--ink)}
|
||||
|
||||
.pub__foot{text-align:center;font-size:.72rem;color:var(--ink-3);padding-top:6px}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
.app{display:grid;grid-template-columns:236px 1fr;min-height:660px}
|
||||
.side{background:var(--panel);border-right:1px solid var(--rule);display:flex;flex-direction:column}
|
||||
.side__brand{height:64px;display:flex;align-items:center;gap:12px;padding:0 20px;border-bottom:1px solid var(--rule);flex-shrink:0}
|
||||
.side__brand .mark{width:32px;height:32px;font-size:.78rem}
|
||||
.side__brand b{font-size:1rem;font-weight:800;letter-spacing:-.035em;display:block;line-height:1.2}
|
||||
.side__nav{padding:16px 12px;display:flex;flex-direction:column;gap:16px}
|
||||
.navgrp + .navgrp{border-top:1px solid var(--rule);padding-top:16px}
|
||||
.navgrp > .cap{padding:0 12px 6px}
|
||||
.navgrp ul{list-style:none;display:flex;flex-direction:column;gap:4px}
|
||||
.navgrp a{position:relative;display:flex;align-items:center;gap:12px;border-radius:var(--r);padding:9px 12px;font-size:.85rem;font-weight:500;color:var(--ink-2);text-decoration:none}
|
||||
.navgrp a:hover{background:var(--panel-2);color:var(--ink)}
|
||||
.navgrp a.on{background:var(--panel-2);color:var(--ink);font-weight:600}
|
||||
.navgrp a.on::before{content:"";position:absolute;left:0;top:4px;bottom:4px;width:2px;border-radius:999px;background:var(--accent)}
|
||||
.navgrp svg{width:16px;height:16px;flex-shrink:0;opacity:.9}
|
||||
|
||||
.main{padding:26px 28px 36px;display:flex;flex-direction:column;gap:22px;min-width:0}
|
||||
.back{font-size:.78rem;color:var(--ink-2);text-decoration:none;display:inline-flex;gap:6px;align-items:center}
|
||||
.back:hover{color:var(--ink)}
|
||||
.phead{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||
.phead h2{font-size:1.3rem;font-weight:800;letter-spacing:-.03em}
|
||||
.record{display:flex;align-items:center;gap:8px;margin-top:6px}
|
||||
.record code{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:3px 8px}
|
||||
.copy{background:none;border:0;color:var(--ink-3);cursor:pointer;font-size:.72rem;font-family:var(--mono)}
|
||||
.copy:hover{color:var(--accent)}
|
||||
.acts{display:flex;gap:9px;flex-wrap:wrap}
|
||||
.btn{font-size:.82rem;font-weight:600;border-radius:var(--r);padding:8px 14px;border:1px solid var(--rule);background:var(--panel);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:7px}
|
||||
.btn:hover{background:var(--panel-2)}
|
||||
.btn--p{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
||||
.btn--p:hover{background:var(--accent-hover)}
|
||||
|
||||
.panel{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
|
||||
.panel__head{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 16px;border-bottom:1px solid var(--rule)}
|
||||
.panel__head h3{font-size:.95rem;font-weight:700}
|
||||
.panel__head p{font-size:.76rem;color:var(--ink-3);margin-top:2px}
|
||||
.panel__body{padding:16px;display:flex;flex-direction:column;gap:16px}
|
||||
|
||||
.fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
|
||||
.field{display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.field > label{font-size:.76rem;font-weight:600;color:var(--ink-2)}
|
||||
.field .hint{font-size:.72rem;color:var(--ink-3)}
|
||||
.in{background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:8px 11px;font:inherit;font-size:.85rem;color:var(--ink);width:100%}
|
||||
.in::placeholder{color:var(--ink-3)}
|
||||
.in:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
|
||||
.in--mono{font-family:var(--mono);font-size:.8rem}
|
||||
|
||||
.toggle{display:flex;align-items:center;justify-content:space-between;gap:16px;background:var(--panel-2);border:1px solid var(--rule);border-radius:var(--r);padding:12px 14px}
|
||||
.toggle p{font-size:.76rem;color:var(--ink-3);margin-top:3px;max-width:52ch}
|
||||
.toggle b{font-size:.85rem}
|
||||
.sw{width:38px;height:21px;border-radius:999px;background:var(--up);border:0;position:relative;cursor:pointer;flex-shrink:0}
|
||||
.sw::after{content:"";position:absolute;top:2px;left:19px;width:17px;height:17px;border-radius:999px;background:var(--accent-ink)}
|
||||
.sw[aria-checked="false"]{background:var(--rule)}
|
||||
.sw[aria-checked="false"]::after{left:2px;background:var(--ink-3)}
|
||||
|
||||
.sect{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel-2)}
|
||||
.sect__head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--rule)}
|
||||
.sect__head .in{max-width:220px}
|
||||
.sect__head .rm{margin-left:auto}
|
||||
.rm{background:none;border:0;color:var(--ink-3);font-size:.75rem;cursor:pointer;font-family:var(--mono)}
|
||||
.rm:hover{color:var(--down)}
|
||||
.entry{display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:center;padding:11px 12px}
|
||||
.entry + .entry{border-top:1px solid var(--rule-soft)}
|
||||
.entry__mon{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.entry__mon b{font-size:.84rem;font-weight:600}
|
||||
.entry__mon span{font-family:var(--mono);font-size:.68rem;color:var(--ink-3)}
|
||||
.adds{display:flex;gap:9px;flex-wrap:wrap;padding:0 12px 12px}
|
||||
|
||||
.inc-row{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:13px 14px}
|
||||
.inc-row + .inc-row{border-top:1px solid var(--rule-soft)}
|
||||
.inc-row__l{min-width:0}
|
||||
.inc-row__l b{font-size:.88rem;font-weight:600;display:block}
|
||||
.inc-row__l span{font-size:.74rem;color:var(--ink-3)}
|
||||
.inc-row__r{display:flex;align-items:center;gap:9px;flex-shrink:0}
|
||||
|
||||
.notes{border-top:1px solid var(--rule);padding-top:14px;display:flex;flex-direction:column;gap:7px}
|
||||
.notes li{font-size:.82rem;color:var(--ink-2);display:flex;gap:10px;list-style:none}
|
||||
.notes li b{color:var(--ink);font-weight:600}
|
||||
.notes .k{font-family:var(--mono);font-size:.66rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);flex:0 0 76px;padding-top:2px}
|
||||
|
||||
@media (max-width:820px){
|
||||
.app{grid-template-columns:1fr}
|
||||
.side{display:none}
|
||||
.entry{grid-template-columns:1fr}
|
||||
.page{padding:32px 16px 64px}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="lede">
|
||||
<p class="cap" style="margin-bottom:10px">Vantage · status pages · mockup for approval</p>
|
||||
<h1>Two screens: what the public sees, and what the operator edits</h1>
|
||||
<p>Drawn with the real <code style="font-family:var(--mono);font-size:.85em">web/</code> dark tokens and the existing sidebar idioms, so what gets approved here is what gets built. The public page is shown mid-incident rather than all-green, because that is the state it exists for.</p>
|
||||
</header>
|
||||
|
||||
<!-- ================= PUBLIC ================= -->
|
||||
<section class="board">
|
||||
<div class="board__head">
|
||||
<p class="cap">1 · Public status page</p>
|
||||
<p class="board__route">web/app/status/[pageId]/page.tsx · no auth, no sidebar</p>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="addr">
|
||||
<span class="addr__dots"><i></i><i></i><i></i></span>
|
||||
<span class="addr__url">https://acme.vantage.example.com<b>/status/api</b></span>
|
||||
<span class="addr__tag">signed out</span>
|
||||
</div>
|
||||
|
||||
<div class="pub">
|
||||
<div class="pub__inner">
|
||||
|
||||
<div class="pub__head">
|
||||
<div class="mark">AC</div>
|
||||
<div>
|
||||
<h2>Acme Platform Status</h2>
|
||||
<p>Live availability for the Acme API and dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overall overall--down">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4"/><path d="M8 4.8v3.6M8 11.1h.01" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Service disruption
|
||||
</div>
|
||||
|
||||
<div class="banner">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.6" aria-hidden="true" style="margin-top:2px">
|
||||
<circle cx="8" cy="8" r="6.4"/><path d="M8 7.4v3.8M8 5.1h.01" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span><b>Europe region only.</b> US and APAC are unaffected. Follow this page for updates.</span>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Active</p>
|
||||
<div class="card">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Elevated error rates on database writes</p>
|
||||
<p class="inc__meta">Started 24 Aug 2026, 09:12 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--mon">monitoring</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Primary database, Public API</p>
|
||||
<div class="timeline">
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">monitoring</span><span class="tl__at">11:40 UTC</span></div>
|
||||
<p class="tl__body">Failover completed. Write latency is back to normal and we are watching for recurrence before calling this resolved.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">identified</span><span class="tl__at">09:48 UTC</span></div>
|
||||
<p class="tl__body">A failing disk on the primary database node is causing write timeouts. Failover to the standby node is in progress.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">investigating</span><span class="tl__at">09:15 UTC</span></div>
|
||||
<p class="tl__body">We are investigating a rise in write errors affecting the API.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Scheduled maintenance</p>
|
||||
<div class="card">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Object storage capacity upgrade</p>
|
||||
<p class="inc__meta">31 Aug 2026, 02:00 – 04:00 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--sch">scheduled</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Object storage</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">API</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="api" data-state="down">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Public API</span>
|
||||
<span class="state"><i class="dot dot--down"></i>Down</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.81%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="hooks" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Webhook delivery</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.99%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Web</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="dash" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Dashboard</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.97%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Data</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="db" data-state="down">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Primary database</span>
|
||||
<span class="state"><i class="dot dot--down"></i>Down</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.62%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="obj" data-state="maint">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Object storage</span>
|
||||
<span class="state"><i class="dot dot--maint"></i>Maintenance</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.94%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="new" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Search index</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>100.00%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Past incidents</p>
|
||||
<div class="card rows">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Public API unavailable</p>
|
||||
<p class="inc__meta">2 Aug 2026, 14:02 UTC — resolved 14:19 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Public API</p>
|
||||
</article>
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Slow dashboard loads in Europe</p>
|
||||
<p class="inc__meta">17 Jul 2026, 08:30 UTC — resolved 10:05 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Dashboard</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pub__foot">Updated 24 Aug 2026, 11:58 UTC · refreshes every 60 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Redacted</span><span>No target URL, host, port or failure text anywhere on this page. <b>Search index</b> shows the no-data tail as grey cells rather than claiming 100% for days before it existed.</span></li>
|
||||
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down — but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
|
||||
<li><span class="k">Colour</span><span>Every state carries a word and a shape as well as a hue. The page is readable with colour vision differences and in greyscale print.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ================= EDITOR ================= -->
|
||||
<section class="board">
|
||||
<div class="board__head">
|
||||
<p class="cap">2 · Status page editor</p>
|
||||
<p class="board__route">web/app/(app)/status-pages/[pageId]/page.tsx · owner or admin</p>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="side__brand">
|
||||
<div class="mark">V</div>
|
||||
<div>
|
||||
<b>Vantage</b>
|
||||
<span class="cap">Acme Ltd</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="side__nav">
|
||||
<div class="navgrp">
|
||||
<p class="cap">Fleet</p>
|
||||
<ul>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="4" rx="1"/><rect x="2" y="9" width="12" height="4" rx="1"/></svg>Servers</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5"/><path d="M6 6h4v4H6z"/></svg>Workloads</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1.5 8.5h3l2-4 3 7 2-3h3"/></svg>Monitors</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navgrp">
|
||||
<p class="cap">Access</p>
|
||||
<ul>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5.5" cy="8" r="3"/><path d="M8.5 8h6M12 8v2.5"/></svg>SSH Keys</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="7" width="10" height="6.5" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 015 0v2"/></svg>Secrets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navgrp">
|
||||
<p class="cap">Instance</p>
|
||||
<ul>
|
||||
<li><a href="#" class="on"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M4.5 10.5v-2M8 10.5v-4M11.5 10.5v-3" stroke-linecap="round"/></svg>Status Pages</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10v10H3z"/><path d="M5.5 6.5h5M5.5 9.5h3"/></svg>Audit Log</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.8v1.6M8 12.6v1.6M14.2 8h-1.6M3.4 8H1.8"/></svg>Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<a class="back" href="#">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M9.5 3.5L5 8l4.5 4.5"/></svg>
|
||||
All status pages
|
||||
</a>
|
||||
|
||||
<div class="phead">
|
||||
<div>
|
||||
<h2>Acme Platform Status</h2>
|
||||
<div class="record">
|
||||
<code>acme.vantage.example.com/status/api</code>
|
||||
<button class="copy" type="button">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<a class="btn" href="#">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M6.5 3.5h6v6M12.5 3.5L7 9"/><path d="M11 10.5v2h-8v-8h2"/></svg>
|
||||
View page
|
||||
</a>
|
||||
<button class="btn btn--p" type="button">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Details</h3>
|
||||
<p>What visitors see at the top of the page.</p>
|
||||
</div>
|
||||
<span class="pill pill--live">published</span>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<div class="toggle">
|
||||
<div>
|
||||
<b>Published</b>
|
||||
<p>Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing.</p>
|
||||
</div>
|
||||
<button class="sw" type="button" role="switch" aria-checked="true" aria-label="Published"></button>
|
||||
</div>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="f-title">Title</label>
|
||||
<input class="in" id="f-title" value="Acme Platform Status">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-id">Page address</label>
|
||||
<input class="in in--mono" id="f-id" value="api" disabled>
|
||||
<span class="hint">Fixed once created — the link is already out there.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-desc">Description</label>
|
||||
<input class="in" id="f-desc" value="Live availability for the Acme API and dashboard.">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-logo">Logo URL</label>
|
||||
<input class="in in--mono" id="f-logo" placeholder="https://acme.example.com/logo.svg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-ban">Notice</label>
|
||||
<input class="in" id="f-ban" value="Europe region only. US and APAC are unaffected. Follow this page for updates.">
|
||||
<span class="hint">Shown above everything else. Clear it to remove the notice.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Components</h3>
|
||||
<p>Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.</p>
|
||||
</div>
|
||||
<button class="btn" type="button">Add section</button>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect__head">
|
||||
<input class="in" value="API" aria-label="Section name">
|
||||
<button class="rm" type="button">remove section</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>prod-api-eu-health</b>
|
||||
<span>http · every 30s</span>
|
||||
</div>
|
||||
<input class="in" value="Public API" aria-label="Public name for prod-api-eu-health">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>hooks-dispatch-probe</b>
|
||||
<span>http · every 60s</span>
|
||||
</div>
|
||||
<input class="in" value="Webhook delivery" aria-label="Public name for hooks-dispatch-probe">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect__head">
|
||||
<input class="in" value="Data" aria-label="Section name">
|
||||
<button class="rm" type="button">remove section</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>pg-primary-10-0-0-5</b>
|
||||
<span>tcp · every 30s</span>
|
||||
</div>
|
||||
<input class="in" value="Primary database" aria-label="Public name for pg-primary-10-0-0-5">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>minio-gw</b>
|
||||
<span>http · every 60s</span>
|
||||
</div>
|
||||
<input class="in" placeholder="minio-gw" aria-label="Public name for minio-gw">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Incidents</h3>
|
||||
<p>Written by you. Outages Vantage detects appear on the page automatically.</p>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn" type="button">Schedule maintenance</button>
|
||||
<button class="btn btn--p" type="button">Open incident</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Elevated error rates on database writes</b>
|
||||
<span>Opened 09:12 UTC · 3 updates · affects Primary database, Public API</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--mon">monitoring</span>
|
||||
<button class="btn" type="button">Post update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Object storage capacity upgrade</b>
|
||||
<span>31 Aug, 02:00–04:00 UTC · affects Object storage</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--sch">scheduled</span>
|
||||
<button class="btn" type="button">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Slow dashboard loads in Europe</b>
|
||||
<span>17 Jul · resolved after 1h 35m · affects Dashboard</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--res">resolved</span>
|
||||
<button class="btn" type="button">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows — so publishing an internal name is always a visible choice.</span></li>
|
||||
<li><span class="k">Address</span><span>The page address is fixed after creation and the record line carries the whole URL, click to copy. It is what gets pasted into a support article.</span></li>
|
||||
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> — the same words the public timeline then shows.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 90 daily cells per component. Seeded rather than random so the mockup is
|
||||
// stable between reloads and reviewers are looking at the same picture.
|
||||
const PATTERNS = {
|
||||
api: { downs: [2, 22], maint: [], noData: 0 },
|
||||
hooks: { downs: [], maint: [], noData: 0 },
|
||||
dash: { downs: [38], maint: [], noData: 0 },
|
||||
db: { downs: [0, 1, 12, 13, 47], maint: [], noData: 0 },
|
||||
obj: { downs: [61], maint: [0], noData: 0 },
|
||||
new: { downs: [], maint: [], noData: 61 }
|
||||
};
|
||||
|
||||
document.querySelectorAll(".comp").forEach((comp) => {
|
||||
const p = PATTERNS[comp.dataset.bar];
|
||||
const bar = comp.querySelector(".bar");
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const cell = document.createElement("span");
|
||||
let cls = "up";
|
||||
if (i >= 90 - p.noData) cls = "none";
|
||||
else if (p.maint.includes(i)) cls = "maint";
|
||||
else if (p.downs.includes(i)) cls = "down";
|
||||
cell.className = cls;
|
||||
cell.title = cls === "none" ? "no data" : cls === "maint" ? "maintenance" : cls === "down" ? "outage" : "operational";
|
||||
frag.appendChild(cell);
|
||||
}
|
||||
bar.appendChild(frag);
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
# Windows agent parity: OS updates and workloads
|
||||
|
||||
Date: 2026-08-13
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the Windows agent up to the Linux agent on two subsystems: OS update
|
||||
check/apply, and the workload registry (collection, control, logs). Everything
|
||||
else about the Windows agent stays as it is.
|
||||
|
||||
Out of scope, deliberately:
|
||||
|
||||
- **Package inventory and CVE findings.** `trivy-db` carries no Windows feed, so
|
||||
a Windows finding needs a different source, a different matcher and a
|
||||
different version comparison. That is its own project, and until it exists a
|
||||
Windows host correctly reports `status: unsupported` rather than "0 findings".
|
||||
- **SSH key management on Windows.** `administrators_authorized_keys` is a real
|
||||
possibility but a separate decision.
|
||||
- **winget.** Third-party app upgrades are a different question from OS
|
||||
patching, and winget is absent on Server Core and older builds.
|
||||
|
||||
## Current state
|
||||
|
||||
The Windows agent registers, heartbeats, reports inventory, runs workflow steps
|
||||
through PowerShell, relays console connections and self-updates via MSI. Four
|
||||
gates stop it doing more:
|
||||
|
||||
| Gate | Location |
|
||||
| --- | --- |
|
||||
| `runtime.GOOS != "linux"` early return | `agent/internal/workloads/workloads.go`, `agent/internal/sync/workloads.go` (twice) |
|
||||
| package-manager detection finds nothing | `agent/internal/updates/updates.go` (`detectPM`) |
|
||||
| hard error | `agent/internal/packages/packages.go` (out of scope here) |
|
||||
| `authorized_keys` write skipped | `agent/internal/sync/sync.go` (out of scope here) |
|
||||
|
||||
## Approach
|
||||
|
||||
The platform split moves into the agent, expressed as build tags following the
|
||||
existing `inventory/collect_linux.go` / `collect_windows.go` /
|
||||
`collect_other.go` precedent. The control plane stays OS-blind: `ReportWorkloads`,
|
||||
`ControlWorkload`, `WorkloadLogs` and `ApplyUpdates` need no changes at all,
|
||||
because a Windows service is reported as the same `unit` kind a systemd service
|
||||
is.
|
||||
|
||||
Build tags rather than `runtime.GOOS` switches so PowerShell command strings do
|
||||
not ship in the Linux binary, and so a platform left unimplemented is a compile
|
||||
error rather than a silent no-op at runtime.
|
||||
|
||||
## Updates
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
agent/internal/updates/
|
||||
updates.go # PackageUpdate; CheckAvailable/ApplyAll declared once
|
||||
updates_linux.go # existing detectPM, checkApt/DnfYum/Pacman/Zypper/Apk, ApplyAll
|
||||
updates_windows.go # Windows Update COM, driven through PowerShell
|
||||
updates_other.go # //go:build !linux && !windows — no-ops
|
||||
```
|
||||
|
||||
`updates_other.go` carries the build constraint for the same reason
|
||||
`inventory/collect_other.go` does: `_other` is not a GOOS suffix, so without the
|
||||
constraint the file compiles everywhere and collides.
|
||||
|
||||
### Checking
|
||||
|
||||
One PowerShell invocation, `-NoProfile -NonInteractive`, emitting JSON:
|
||||
|
||||
```powershell
|
||||
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
```
|
||||
|
||||
The Windows Update COM API is used rather than the `PSWindowsUpdate` module
|
||||
because it is present on every supported Windows, needs no PowerShell Gallery
|
||||
install, and works unchanged against a WSUS server on an air-gapped fleet. The
|
||||
agent runs as `LocalSystem`, which has the rights the API requires.
|
||||
|
||||
Each result maps to a `PackageUpdate`:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `Name` | the update Title |
|
||||
| `CurrentVersion` | empty |
|
||||
| `NewVersion` | the KB article ID, e.g. `KB5034123` |
|
||||
|
||||
`CurrentVersion` is empty because a Windows update is not a version bump of a
|
||||
named package, and inventing a current version would put a wrong string in front
|
||||
of an operator. The KB ID goes in `NewVersion` because it is the identifier
|
||||
people actually search for.
|
||||
|
||||
Timeout: 10 minutes. The first search after a boot contacts Microsoft Update and
|
||||
is routinely slow.
|
||||
|
||||
### Applying
|
||||
|
||||
The same COM session: `CreateUpdateDownloader` then `CreateUpdateInstaller`,
|
||||
over the updates returned by the search above, skipping any that require user
|
||||
input. EULAs are accepted programmatically; an update whose EULA cannot be
|
||||
accepted is skipped rather than failing the batch.
|
||||
|
||||
The operation fails when the installer's `ResultCode` is not 2 (succeeded) or 3
|
||||
(succeeded with errors).
|
||||
|
||||
Timeout: 60 minutes. A patch-Tuesday cumulative genuinely takes that long, and
|
||||
the Linux path's existing 5-minute cap is already tight.
|
||||
|
||||
**The agent never reboots the host.** A control plane silently restarting a
|
||||
production server is unrecoverable from the UI, and the reboot is a decision a
|
||||
person or a workflow makes. Instead the need for one is reported.
|
||||
|
||||
### Reboot required
|
||||
|
||||
A new field `reboot_required` on `InventoryReport`, added to
|
||||
`proto/vantage/v1/vantage.proto` and to both hand-written `pb` copies
|
||||
(`agent/internal/grpc/pb`, `server/internal/grpc/pb`) in the same commit.
|
||||
|
||||
It travels on the inventory report rather than the update report because it is a
|
||||
host property like the kernel version, and it is set on the **static** snapshot
|
||||
only — every 15 minutes rather than every 30 seconds. A host rebooted by hand
|
||||
clears the flag in a quarter of an hour instead of showing it for up to a full
|
||||
one, and the detection costs a PowerShell process on Windows, which is not
|
||||
something to spawn twice a minute forever.
|
||||
|
||||
It is set in `agentsync.runInventory`, not inside the `inventory` package, so
|
||||
`inventory` gains no dependency on `updates`.
|
||||
|
||||
Both platforms set it, since parity is free here:
|
||||
|
||||
- Linux: `/var/run/reboot-required` exists, or `dnf needs-restarting -r` exits
|
||||
non-zero.
|
||||
- Windows: the `Microsoft.Update.SystemInfo` COM object's `RebootRequired`
|
||||
property, falling back to the pending-reboot registry keys
|
||||
(`Component Based Servicing\RebootPending`,
|
||||
`WindowsUpdate\Auto Update\RebootRequired`,
|
||||
`Session Manager\PendingFileRenameOperations`).
|
||||
|
||||
`services.ReportInventory` stores it on `servers.inventory`.
|
||||
|
||||
## Workloads
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
agent/internal/workloads/
|
||||
workloads.go # Result, Collect, Hash — Collect calls collectUnits
|
||||
docker.go # unchanged, shared: shells to the docker binary
|
||||
systemd_linux.go # was systemd.go
|
||||
services_windows.go # new: Win32_Service collection
|
||||
control.go # shared validation; platform halves split out
|
||||
control_linux.go # docker/systemctl, /proc/self/cgroup own-container check
|
||||
control_windows.go # Start/Stop/Restart-Service, VantageAgent protection
|
||||
logs.go # shared: capLog, MaxLogLines, MaxLogBytes
|
||||
logs_linux.go # docker logs / journalctl
|
||||
logs_windows.go # docker logs / Get-WinEvent
|
||||
```
|
||||
|
||||
`Collect` loses its `runtime.GOOS != "linux"` return and calls
|
||||
`collectUnits(ctx)`, which is the systemd collector on Linux and the service
|
||||
collector on Windows. `runWorkloads` and `reportWorkloads` in
|
||||
`agent/internal/sync/workloads.go` lose all three of their platform returns.
|
||||
|
||||
`docker.go` stays shared and ungated. It shells to the `docker` binary, which
|
||||
behaves identically on Windows, so a Docker Desktop or Mirantis host reports its
|
||||
containers with no new code. `DockerOK` / `DockerError` keep their existing
|
||||
three-state meaning: not installed (the common case, not a fault), installed but
|
||||
not responding, and running nothing.
|
||||
|
||||
### Collecting Windows services
|
||||
|
||||
`Get-CimInstance Win32_Service` converted to JSON — not `Get-Service`, which
|
||||
exposes neither `PathName` nor `StartMode`, and the filter needs both.
|
||||
|
||||
A service is reported when its executable does **not** resolve under
|
||||
`%SystemRoot%\System32`, and it is running, failed, or has `StartMode=Auto`
|
||||
while stopped. This mirrors the systemd collector's intent: show what an
|
||||
operator installed, and show what is meant to be up but is not.
|
||||
|
||||
Path parsing strips surrounding quotes and trailing arguments before the
|
||||
`%SystemRoot%` comparison. `"C:\Program Files\X\x.exe" -service` is one path
|
||||
with one argument, and splitting naively on whitespace misfiles a substantial
|
||||
share of a real fleet.
|
||||
|
||||
Field mapping:
|
||||
|
||||
| Workload field | Source |
|
||||
| --- | --- |
|
||||
| `Kind` | `"unit"` |
|
||||
| `ID` | `Name` (the service name) |
|
||||
| `Name` | `DisplayName` |
|
||||
| `State` | `running` / `stopped` / `failed`, from `State` plus `ExitCode` |
|
||||
| `Health`, `Image`, `Stack`, `Ports`, `Restarts` | unset |
|
||||
|
||||
`Kind: "unit"` and the existing `systemd_ok` / `systemd_error` fields are reused
|
||||
rather than a `service` kind and `services_ok` fields being added. That would
|
||||
cost a proto change, both pb copies, the server model, the service layer and the
|
||||
web client, and would teach every existing consumer a second kind — to describe
|
||||
the same thing. The naming is corrected where it is read, in the UI, which knows
|
||||
the server's OS.
|
||||
|
||||
`State` values match the ones the UI already colours, so no web change is needed
|
||||
for the rows themselves.
|
||||
|
||||
### Protection
|
||||
|
||||
The protected set stays computed and enforced agent-side, as it is on Linux: the
|
||||
control plane may name a target, but the agent decides what it will do to
|
||||
itself.
|
||||
|
||||
On Windows the protected workload is the `VantageAgent` service — the NSSM
|
||||
service name written by `installer/setup.ps1` — matched case-insensitively,
|
||||
because Windows service names are. `detectOwnContainer` and its
|
||||
`/proc/self/cgroup` read move to `control_linux.go`; the Windows build returns
|
||||
no own-container ID.
|
||||
|
||||
`ErrProtected` still surfaces as HTTP 409 from the API, and the reported
|
||||
`Protected` flag remains a courtesy that greys the button rather than the
|
||||
boundary.
|
||||
|
||||
### Control
|
||||
|
||||
`Start-Service`, `Stop-Service -Force`, `Restart-Service -Force`, under the same
|
||||
90-second `controlTimeout`, with the error text taken from PowerShell's stderr.
|
||||
|
||||
`sc.exe` is avoided because it returns before the operation completes, which
|
||||
turns a timeout into a false success. `-Force` is required because
|
||||
`Stop-Service` without it refuses when other services depend on the target, and
|
||||
that refusal reads to an operator as a silent no-op.
|
||||
|
||||
### Logs
|
||||
|
||||
`Get-WinEvent` with a filter hashtable over the `System` and `Application` logs,
|
||||
provider names matching the service name, its display name, and
|
||||
`Service Control Manager`, newest first, capped by the requested tail.
|
||||
|
||||
Each event is formatted as `<ISO 8601 timestamp> <Level> <Message>`, which is
|
||||
the same shape `journalctl --output=short-iso` produces, so the log dialog needs
|
||||
no per-platform rendering.
|
||||
|
||||
Service Control Manager logs every service on the host under one provider, so
|
||||
its events are filtered client-side to those naming the target service.
|
||||
|
||||
`capLog` is shared and unchanged: 500 lines and 256KB, whichever binds first,
|
||||
trimmed from the front. There is still no follow mode.
|
||||
|
||||
An empty result returns an empty string and no error. A service that has logged
|
||||
nothing is normal, and an error there would read as a broken feature.
|
||||
|
||||
## Server and web
|
||||
|
||||
The server changes in one place: `services.ReportInventory` persists
|
||||
`reboot_required`.
|
||||
|
||||
The web changes in three, all keyed on the same `os_info` test
|
||||
`MaintenanceTab.tsx` already uses (`server.os_info?.toLowerCase().includes("windows")`)
|
||||
rather than on `os_type`. `os_type` is stored and serialised but unread by
|
||||
`web/` today, and introducing a second Windows test in the same component is how
|
||||
the two come to disagree. `WorkloadList` takes the result as a prop, since it
|
||||
receives only a `serverId`:
|
||||
|
||||
1. `web/components/workloads/WorkloadList.tsx` — takes an `isWindows` prop from
|
||||
the server detail page, and the systemd status lines become
|
||||
platform-worded. On Windows the error line reads "Windows services could not
|
||||
be read" and the "systemd is not in use on this server" line is not rendered
|
||||
at all. The empty-state line drops "on Linux only". The Docker lines are
|
||||
unchanged.
|
||||
2. Server detail — a `Reboot required` pill beside the update count when the
|
||||
flag is set, placed with the update panel because that is what caused it.
|
||||
3. The Updates panel's Windows copy describes a list of KB articles rather than
|
||||
package upgrades, since `current_version` is empty on that platform.
|
||||
|
||||
## Testing
|
||||
|
||||
The Windows collectors are, in substance, parsers of PowerShell output. Parsing
|
||||
is separated from invocation and table-tested against captured real output. The
|
||||
`agent` module has no tests at all today, so these are the first — they live
|
||||
beside the parsers as ordinary `_test.go` files, run with `go test ./...` from
|
||||
`agent/`, and need no new dependency:
|
||||
|
||||
- `Win32_Service` JSON, including a quoted path with arguments, a
|
||||
`%SystemRoot%\System32` service that must be filtered out, a stopped
|
||||
`StartMode=Auto` service that must be kept, and a failed service with a
|
||||
non-zero `ExitCode`.
|
||||
- Update searcher JSON, including an update with no KB ID.
|
||||
- `Get-WinEvent` JSON, including a Service Control Manager event for another
|
||||
service that must be filtered out.
|
||||
- Pending-reboot detection from registry key presence.
|
||||
|
||||
`capLog` and `Hash` are unchanged and gain no tests.
|
||||
|
||||
The invocation halves are verified by hand on a Windows host: check, apply,
|
||||
service start/stop/restart, a protected refusal on `VantageAgent`, and logs on
|
||||
both a chatty service and a silent one.
|
||||
|
||||
`GOOS=windows go build ./...` and `GOOS=linux go build ./...` both belong in the
|
||||
implementation plan as explicit steps — a build-tag split is exactly the change
|
||||
that compiles on the machine you are sitting at and nowhere else. CI already
|
||||
cross-builds the agent on release, so no workflow change is needed.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Public status pages
|
||||
|
||||
Date: 2026-08-24
|
||||
|
||||
## Goal
|
||||
|
||||
Let an operator publish one or more public status pages from a Vantage
|
||||
instance, at `<slug>.vantage.<tld>/status/<page-id>`, showing the state of any
|
||||
monitors they choose, plus incidents and maintenance windows they author by
|
||||
hand. The pages are completely public: no session, no token, no login.
|
||||
|
||||
Out of scope, deliberately:
|
||||
|
||||
- **Custom domains** (`status.customer.com`). Needs certificate provisioning and
|
||||
a host-to-page lookup that bypasses `hostSlug` entirely. Its own sub-project.
|
||||
- **Per-page themes.** `web/` is locked dark by design and a public page is not
|
||||
the place to break that.
|
||||
- **Subscriber notifications.** Email or webhook on incident updates is a
|
||||
notification subsystem, and one already exists for monitors; wiring the two
|
||||
together is a separate decision.
|
||||
- **SLA reporting.** Uptime percentages are shown; contractual SLA calculation
|
||||
with credits and exclusions is a different product.
|
||||
|
||||
## Current state
|
||||
|
||||
Everything needed to draw a status page already exists and is already scoped by
|
||||
instance:
|
||||
|
||||
| Data | Where |
|
||||
| --- | --- |
|
||||
| Monitor identity and live state | `models.Monitor`, `Monitor.State` |
|
||||
| Outage records | `models.Incident`, opened when a monitor flips down |
|
||||
| Hourly uptime history | `models.Rollup` (`monitor_rollups`) |
|
||||
| Sub-hour history | `models.MonitorSample`, TTL-expired |
|
||||
| Instance from hostname | `auth.InstanceFromHost`, 60s cached |
|
||||
|
||||
Three things do not exist: any concept of a page, any operator-authored
|
||||
incident, and any unauthenticated read path. The third is the constraint that
|
||||
shapes the rest — every route under `/api` carries `auth.Middleware`,
|
||||
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
|
||||
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
|
||||
no scope entry.
|
||||
|
||||
## Approach
|
||||
|
||||
Two new collections hold the page and the authored incidents. A single
|
||||
assembly function reads them alongside the existing monitor data and emits a
|
||||
purpose-built public struct. The public route is mounted outside `/api`, is
|
||||
cached in Redis, and is rate limited per client address.
|
||||
|
||||
The redaction boundary is the assembly function, and it is the security
|
||||
property of this whole feature.
|
||||
|
||||
## Data model
|
||||
|
||||
Both collections carry `instance_id` and both must be added to
|
||||
`services.ScopedCollections`, or their rows outlive a deleted instance.
|
||||
|
||||
### `status_pages`
|
||||
|
||||
One document per page. It is read whole, always, so its structure is embedded
|
||||
rather than joined: one page is one Mongo read is one cache fill.
|
||||
|
||||
```
|
||||
_id, instance_id
|
||||
page_id // operator-chosen slug, [a-z0-9-], 3-40 chars
|
||||
title, description, logo_url
|
||||
published bool
|
||||
banner { enabled, level, text }
|
||||
sections [ { name, entries: [ { monitor_id, display_name } ] } ]
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
Unique index on `(instance_id, page_id)`. The slug is operator-chosen rather
|
||||
than random because it is a URL handed to customers and printed on support
|
||||
pages; a random identifier would be unguessable and unmemorable in equal
|
||||
measure.
|
||||
|
||||
`published` exists so a page can be composed before anyone sees it. An
|
||||
unpublished page answers the same 404 as a page that does not exist — a
|
||||
distinct 403 would confirm it exists.
|
||||
|
||||
Sections are page-local and unrelated to `Monitor.Group`, which is a display
|
||||
label on the authenticated monitors list. One monitor may appear under "API" on
|
||||
the customer page and "Edge" on the partner page, under two different display
|
||||
names. That is the point of the override: a monitor's internal name is often
|
||||
not a name you want published.
|
||||
|
||||
The banner is three fields on the page rather than a collection, because it is
|
||||
one string with no lifecycle.
|
||||
|
||||
### `status_incidents`
|
||||
|
||||
Manual incidents and maintenance windows share one shape, because they share a
|
||||
timeline, an impact and a set of affected components; splitting them into two
|
||||
collections would duplicate all three.
|
||||
|
||||
```
|
||||
_id, instance_id, incident_id
|
||||
page_ids []string // which pages show it
|
||||
kind "incident" | "maintenance"
|
||||
title
|
||||
impact // none | minor | major | critical
|
||||
affected_monitors []string // monitor_ids
|
||||
status // incident: investigating | identified | monitoring | resolved
|
||||
// maintenance: scheduled | in_progress | completed
|
||||
scheduled_start, scheduled_end // maintenance only
|
||||
updates [ { at, status, body, author } ]
|
||||
started_at, resolved_at, created_at, updated_at
|
||||
```
|
||||
|
||||
Updates are embedded for the same reason sections are: they are few, and they
|
||||
are never read apart from their incident.
|
||||
|
||||
`page_ids` is explicit rather than derived from `affected_monitors`. Deriving it
|
||||
would be less to fill in, but adding a monitor to a page later would
|
||||
retroactively republish old incidents to a new audience. An operator publishing
|
||||
to customers chooses that audience.
|
||||
|
||||
### Auto-incidents are derived, never copied
|
||||
|
||||
The existing `incidents` collection remains the only writer for
|
||||
monitor-detected outages. The public snapshot derives them at assembly time:
|
||||
filter to the monitors on the page, last 90 days, render as display name, start,
|
||||
end and duration.
|
||||
|
||||
`Incident.Cause` is dropped. It is where `dial tcp 10.0.0.5:5432: connect
|
||||
refused` lives.
|
||||
|
||||
Copying auto-incidents into `status_incidents` would be a second writer for the
|
||||
same fact, arriving by a different route with its own opportunity to disagree —
|
||||
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
|
||||
inline.
|
||||
|
||||
### Maintenance does not rewrite uptime
|
||||
|
||||
During a maintenance window, affected components render as "under maintenance"
|
||||
rather than down. The uptime percentage and the history bar still come from the
|
||||
rollups, unmodified.
|
||||
|
||||
Rollups are the durable record. Bending them so a page looks better is a lie
|
||||
pointed the other way, and the operator who later asks "what was our actual
|
||||
availability" gets an answer that was edited for publication.
|
||||
|
||||
## The redaction boundary
|
||||
|
||||
`services.BuildStatusSnapshot(instanceID, pageID)` is the only function that
|
||||
reads `monitors`, `incidents`, `monitor_rollups` and `status_incidents` on
|
||||
behalf of an anonymous caller, and it emits a purpose-built struct.
|
||||
|
||||
**`models.Monitor` is never marshalled to a public caller.** Target URL, host,
|
||||
port, method, keyword, `state.message`, `state.cert_expiry_at` and
|
||||
`channel_ids` all stay behind the boundary. A field added to `Monitor` next year
|
||||
is private by default rather than published by accident.
|
||||
|
||||
What the snapshot contains, per entry: display name, current status, uptime
|
||||
percentage over the last 90 days, and a 90-day history bar of one cell per day.
|
||||
A cell is up, down, under maintenance, or no-data — `no-data` for days before
|
||||
the monitor existed, which is a distinct thing from a day it was down. No
|
||||
latency, no addresses, no failure text.
|
||||
|
||||
## Public read path
|
||||
|
||||
```
|
||||
GET /public/status/:pageId
|
||||
```
|
||||
|
||||
Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would
|
||||
require exempting it from authentication, scope enforcement, token rate
|
||||
limiting and the licence gate — four holes, each one something a later change
|
||||
can widen. Outside `/api` it needs none of them.
|
||||
|
||||
The instance is resolved from the request host through `auth.InstanceFromHost`.
|
||||
A host with no instance label, an unknown slug, an unknown page and an
|
||||
unpublished page all answer **404**, identically.
|
||||
|
||||
### The feature gate answers 200, not 403
|
||||
|
||||
Status pages are gated by a new `license.FeatureStatusPages = "status_pages"`,
|
||||
on both the authoring routes and the public read.
|
||||
|
||||
The public side checks inline rather than through `RequireFeature`, which
|
||||
aborts with a 403 JSON body. A public page needs to render an explanation:
|
||||
|
||||
```json
|
||||
{ "available": false, "reason": "feature_unavailable", "title": "Acme Status" }
|
||||
```
|
||||
|
||||
`reason` is `feature_unavailable` when the tier does not include the feature and
|
||||
`licence_inactive` when the licence has lapsed. The title is included so the
|
||||
page does not look broken; nothing else is.
|
||||
|
||||
**This is not only a server change.** The feature must be added to admin's
|
||||
`plans` rows per `(deployment, tier)`, or every instance reads it as absent and
|
||||
the feature ships dark.
|
||||
|
||||
### Cache
|
||||
|
||||
Redis key `vantage:status:<instance_id>:<page_id>` holds the assembled JSON with
|
||||
a 30-second TTL. N visitors cost one Mongo read regardless of traffic.
|
||||
|
||||
Authoring writes delete the key, so an operator posting an incident update sees
|
||||
it immediately rather than wondering for half a minute whether it saved.
|
||||
|
||||
Redis rather than Next ISR because with `replicaCount > 1` each `web` pod would
|
||||
cache separately and two visitors would see different states during an incident.
|
||||
|
||||
### Rate limit
|
||||
|
||||
Per client address, one-minute fixed window, 120 requests, 429 with
|
||||
`Retry-After` — the same shape as `RateLimitTokens`, including its most
|
||||
important property: **when Redis is unavailable, allow rather than deny.** A
|
||||
status page must survive the outage it exists to report.
|
||||
|
||||
### Trusted proxies
|
||||
|
||||
Nothing calls `r.SetTrustedProxies`, so gin trusts every proxy and
|
||||
`c.ClientIP()` takes `X-Forwarded-For` verbatim. That is spoofable per request,
|
||||
which makes a per-address limiter decorative.
|
||||
|
||||
This has not mattered so far because `ClientIP()` is only used for audit
|
||||
strings. It matters now, so this work adds a trusted-proxy configuration and
|
||||
sets it at boot. Without it the rate limit is theatre.
|
||||
|
||||
## Authoring API
|
||||
|
||||
Under `/api`, owner or admin, behind `RequireFeature("status_pages")`, every
|
||||
mutation audited:
|
||||
|
||||
```
|
||||
GET,POST /status-pages
|
||||
GET,PUT,DELETE /status-pages/:pageId
|
||||
GET,POST /status-pages/:pageId/incidents
|
||||
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
|
||||
POST /status-pages/:pageId/incidents/:incidentId/updates
|
||||
```
|
||||
|
||||
This adds a ninth scope resource, `status:read` and `status:write`. The entries
|
||||
are required, not optional: `AssertScopeMapComplete` fails boot on an `/api`
|
||||
route with no scope entry, which is exactly the safeguard working.
|
||||
|
||||
Handlers need `@…` annotations and `openapi.json` must be regenerated and
|
||||
committed — `server-deploy.yml` runs `git diff --exit-code` against the
|
||||
committed copy, so a handler whose annotation drifted fails CI.
|
||||
|
||||
## Frontend
|
||||
|
||||
`web/app/status/[pageId]/page.tsx`, **outside the `(app)` route group**, so it
|
||||
inherits no sidebar, no session fetch and no auth redirect. Server-rendered
|
||||
against the Go endpoint, with a client refresh every 60 seconds.
|
||||
|
||||
`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh
|
||||
reaches the server.
|
||||
|
||||
The page stays dark, like the rest of `web/`, and carries no hex values — the
|
||||
existing token palette covers every state it needs.
|
||||
|
||||
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
|
||||
group. It is `adminOnly`, and since the whole group is, a member sees the group
|
||||
disappear entirely rather than a labelled section with nothing under it.
|
||||
|
||||
## Testing
|
||||
|
||||
The snapshot tests are the ones that matter, because they are the redaction
|
||||
boundary made executable:
|
||||
|
||||
- `BuildStatusSnapshot` output contains no target URL or host, no
|
||||
`state.message`, no `incident.cause`, no `channel_ids`, no latency.
|
||||
- A monitor on no page never appears in any page's snapshot.
|
||||
- An unpublished page and an unknown page both 404.
|
||||
- Feature absent and licence inactive both return 200 with `available: false`
|
||||
and the matching `reason`.
|
||||
- A cache hit performs no Mongo read; an authoring write invalidates the key.
|
||||
- Slug validation: character set, length, uniqueness within an instance.
|
||||
- Maintenance window renders the component as under maintenance while leaving
|
||||
the uptime percentage untouched.
|
||||
|
||||
## Migration and rollout
|
||||
|
||||
No migration is needed — both collections are new and absent means empty. Index
|
||||
builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being
|
||||
fatal: a missing index on a small collection degrades to a scan, which is no
|
||||
reason to refuse to serve the fleet.
|
||||
|
||||
The feature ships dark until the `status_pages` feature is added to the plan
|
||||
rows in admin.
|
||||
@@ -0,0 +1,330 @@
|
||||
# Control plane backup and restore
|
||||
|
||||
Date: 2026-09-07
|
||||
Status: approved, ready for implementation planning
|
||||
|
||||
## Problem
|
||||
|
||||
Vantage has no backup story. A self-hosted deployment holds its entire state in
|
||||
MongoDB and encrypts the sensitive half of it — SSH private keys, key
|
||||
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials — with
|
||||
AES-256-GCM under a single 32-byte key supplied as the `KEY_ENCRYPTION_KEY`
|
||||
environment variable.
|
||||
|
||||
That key is a bare value. It carries no identifier, is not wrapped, and is not
|
||||
recorded anywhere alongside the data it protects. Restoring a database without
|
||||
it produces a control plane whose every secret is permanently unreadable, and
|
||||
nothing in the product tells an operator this before it happens.
|
||||
|
||||
`mongodump` exists and operators can use it, but it says nothing about the
|
||||
encryption key, so the most common way to lose everything is to hold a perfectly
|
||||
good database dump and no key.
|
||||
|
||||
## Goal
|
||||
|
||||
A standalone command-line tool that backs up and restores a whole Vantage
|
||||
deployment, and that makes the key relationship impossible to get wrong by
|
||||
accident.
|
||||
|
||||
Explicitly not a goal: point-in-time recovery, incremental backups, built-in
|
||||
storage backends, encryption of the archive itself, per-tenant export, and
|
||||
backups scheduled from inside the server. Each is a separate decision and
|
||||
several are better served by tools the operator already has.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope of a backup
|
||||
|
||||
One backup covers one MongoDB database: every collection in it, whether or not
|
||||
that collection is tenant-scoped. A deployment-level disaster recovery tool that
|
||||
skipped `migrations` or `vulndb_meta` would restore a database the server
|
||||
refuses to boot against.
|
||||
|
||||
Collections are enumerated live with `ListCollectionNames` rather than read from
|
||||
a hardcoded list. This is the opposite choice to `services.ScopedCollections`,
|
||||
and deliberately so: that list can afford to be hand-maintained because
|
||||
`AssertNoScopedCollectionMissed` fails boot when it drifts. A backup tool has no
|
||||
such assertion available, so a second hand-maintained registry would drift
|
||||
silently and the first symptom would be a restore missing a collection nobody
|
||||
noticed was added.
|
||||
|
||||
`--exclude` accepts collection names for the volume-heavy ones —
|
||||
`workflow_log_lines`, `monitor_samples`, `audit_logs`. Whatever is excluded is
|
||||
recorded in the manifest, so an archive can never claim to be complete when it
|
||||
is not.
|
||||
|
||||
Redis is not backed up. It holds sessions only; losing it logs everyone out and
|
||||
nothing else, which is already the documented behaviour. The restore output says
|
||||
so explicitly rather than leaving an operator to wonder.
|
||||
|
||||
### Where the code lives
|
||||
|
||||
Two units.
|
||||
|
||||
`shared/backup/` holds the logic: dump, restore, manifest construction, archive
|
||||
reading and writing, and key fingerprinting. It depends on the MongoDB driver
|
||||
and the standard library, and on no CLI framework. Keeping it in `shared/` and
|
||||
free of cobra is what lets `server` import it later if backups scheduled from
|
||||
inside the control plane are ever built, without pulling a command-line parser
|
||||
into the server binary.
|
||||
|
||||
`vantagectl/` is a new module in `go.work`, importing `shared`. It holds the
|
||||
cobra command tree and nothing else. A separate module rather than a package
|
||||
under `shared/` because adding cobra to `shared/go.mod` would put cobra and
|
||||
pflag into the module graph of `server`, `admin` and `sitesvc`, none of which
|
||||
use them. Binaries are unaffected — Go links only what is imported — but three
|
||||
`go.sum` files would grow and three CI builds would fetch a dependency they do
|
||||
not need. `agent/` is already a separate module for the same reason.
|
||||
|
||||
The tool imports nothing from `server/`. No `db.Col()`, no `services`, no config
|
||||
loader, and it never dials the REST or gRPC API. It needs only network reach to
|
||||
MongoDB, a database name, and `KEY_ENCRYPTION_KEY` in its own environment. This
|
||||
is what lets it run against a control plane that is down, half-migrated, or was
|
||||
deleted an hour ago — which is the only condition under which anyone runs a
|
||||
restore.
|
||||
|
||||
### Dump implementation
|
||||
|
||||
The dump is written against the MongoDB driver, not by shelling out to
|
||||
`mongodump`.
|
||||
|
||||
Two reasons. `server`'s runtime image is `scratch` and carries no shell and no
|
||||
mongo tools, so a wrapper would depend on a matching `mongodump` version being
|
||||
installed on whatever host runs the tool. And the manifest must be written by
|
||||
the same process that read the documents, or the fingerprint and per-collection
|
||||
checksums are claims about data the writer never saw.
|
||||
|
||||
The cost is that BSON round-tripping is ours to get right. Documents are written
|
||||
as raw BSON exactly as the driver returns them, without an intermediate map, so
|
||||
`ObjectId`, `Decimal128`, `DateTime`, binary subtypes and nulls survive
|
||||
unchanged. A round-trip test asserting byte-equal BSON is the guard.
|
||||
|
||||
### Archive format
|
||||
|
||||
A gzipped tar named `vantage-backup-<db>-<RFC3339>.tar.gz`:
|
||||
|
||||
```
|
||||
manifest.json
|
||||
collections/<name>.bson concatenated raw BSON documents
|
||||
indexes/<name>.json index specifications
|
||||
```
|
||||
|
||||
`manifest.json` carries:
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `format_version` | Currently `1`. Restore refuses an unknown version rather than guessing at it |
|
||||
| `created_at` | RFC3339, UTC |
|
||||
| `vantage_version` | Build stamp of the tool that wrote the archive |
|
||||
| `hostname` | Provenance; which machine produced this |
|
||||
| `mongo_db` | Source database name |
|
||||
| `mongo_server_version` | Restore warns on a major version gap |
|
||||
| `key_fingerprint` | `sha256` of the raw 32 key bytes, hex, or `null`. Never the key |
|
||||
| `collections[]` | Per collection: name, document count, uncompressed bytes, `sha256` of the `.bson` member |
|
||||
| `excluded[]` | Collection names passed to `--exclude` |
|
||||
|
||||
Per-collection checksums mean a truncated or corrupted archive is detected
|
||||
before a single document is written, rather than halfway through a restore.
|
||||
|
||||
### Key custody
|
||||
|
||||
The key never enters the archive. The archive is exactly as sensitive as a
|
||||
`mongodump` of the same database, and no more.
|
||||
|
||||
What the archive carries is `sha256` of the raw key bytes. A hash of the key
|
||||
proves identity without being a hint at the value, which is what allows an
|
||||
operator to answer "will this archive restore into this deployment" without
|
||||
holding both in front of them.
|
||||
|
||||
Backup refuses to run when `KEY_ENCRYPTION_KEY` is unset or malformed. An
|
||||
archive full of ciphertext whose key was never recorded is worse than no archive
|
||||
at all, because it looks like a backup. `--allow-no-key` exists for a deployment
|
||||
that genuinely stores no encrypted material; it stamps `key_fingerprint: null`,
|
||||
which restore then reports loudly rather than treating as a match.
|
||||
|
||||
Restore compares the archive's fingerprint against the key in the current
|
||||
environment:
|
||||
|
||||
- Fingerprints match: proceed.
|
||||
- Fingerprints differ: refuse, printing both.
|
||||
- Archive has a fingerprint, environment has no key: refuse.
|
||||
- `--ignore-key-mismatch`: proceed, having first printed exactly which
|
||||
collections hold ciphertext that will be undecryptable — `keys`, `secrets`,
|
||||
`auth_providers`, `console_sessions`, `settings`.
|
||||
|
||||
### Restore semantics
|
||||
|
||||
The order is fixed:
|
||||
|
||||
1. Read `manifest.json` and check `format_version`.
|
||||
2. Verify every archive member against its manifest checksum. Nothing is written
|
||||
before this passes.
|
||||
3. Apply the fingerprint rules above.
|
||||
4. Inspect the target: `ListCollectionNames` and document counts. A non-empty
|
||||
database is refused, printing what was found. `--force` proceeds.
|
||||
5. Per collection: under `--force`, drop it first; then bulk-insert in batches
|
||||
of 1000 with `ordered=false`.
|
||||
6. Replay index specifications from `indexes/<name>.json`, skipping `_id_`.
|
||||
7. Print a summary: collection, documents restored, indexes created.
|
||||
|
||||
Restore is not idempotent, and says so. A second run without `--force` is
|
||||
refused because step 4 now finds data. A restore interrupted during step 5
|
||||
leaves a partial database that the next run refuses to touch — correct, because
|
||||
the alternative is a silent merge. There are no merge or upsert semantics at
|
||||
all: merging two control planes reconciles nothing and produces a fleet that
|
||||
half works, and upserting by `_id` resurrects rows deleted since the backup,
|
||||
which for revoked keys and deleted users is a security regression wearing the
|
||||
costume of a convenience.
|
||||
|
||||
Index replay is fatal per collection when a unique index fails to build, and a
|
||||
warning when a non-unique one does. A unique index that cannot be created means
|
||||
the restored data violates it, and the unique indexes here — `(instance_id,
|
||||
email)`, instance slug, settings instance, the ESO token hash — are
|
||||
tenant-isolation properties rather than optimisations. The failure names the
|
||||
offending index.
|
||||
|
||||
### Destructive confirmation
|
||||
|
||||
Restore under `--force` requires a typed confirmation when stdin is a TTY.
|
||||
|
||||
When stdin is not a TTY — a Kubernetes Job, a CI step, a cron entry — the
|
||||
confirmation comes from `--confirm-db <name>`, whose value must equal the
|
||||
resolved target database name or restore refuses. Naming the database in the
|
||||
argument means a copy-pasted restore command carries its intended target with
|
||||
it and cannot destroy a different one.
|
||||
|
||||
A dynamic flag name containing the database name was considered and rejected:
|
||||
cobra registers flags before parsing, and the target database is not known at
|
||||
registration time.
|
||||
|
||||
### Command surface
|
||||
|
||||
```
|
||||
vantagectl root; prints help
|
||||
├── backup --out DIR|- --exclude a,b --allow-no-key
|
||||
├── restore ARCHIVE --force --confirm-db NAME --ignore-key-mismatch
|
||||
├── inspect ARCHIVE
|
||||
└── verify ARCHIVE
|
||||
```
|
||||
|
||||
Persistent flags on the root command, so every subcommand accepts them and they
|
||||
are documented once: `--mongo-uri` (env `MONGO_URI`) and `--db` (env `MONGO_DB`,
|
||||
falling back to the URI path). There is no `--log-level`: the tool's entire
|
||||
output is what it is telling the operator, and a level that could hide a key
|
||||
warning is worth not having.
|
||||
|
||||
Environment fallback is wired with an explicit `Changed` check on each flag
|
||||
rather than through viper. Viper is a configuration-file and remote-config
|
||||
system; this tool reads no configuration file, and pulling it in to call
|
||||
`os.Getenv` would make the largest dependency in the binary the one doing the
|
||||
smallest job.
|
||||
|
||||
`inspect` prints the manifest — when the archive was made, by what version,
|
||||
which collections it holds, how many documents, what was excluded, and the key
|
||||
fingerprint — and contacts no database. It is what an operator runs to find out
|
||||
whether an archive they have found is worth anything.
|
||||
|
||||
`verify` adds a live check: whether the archive's fingerprint matches the key in
|
||||
the current environment, and — when `--mongo-uri` is given — whether that key
|
||||
actually decrypts the target database. The second half is a probe: read one
|
||||
ciphertext field from `secrets`, `keys` or `auth_providers` and attempt to open
|
||||
it. A fingerprint comparison proves two archives agree; only a probe proves the
|
||||
key in hand opens the data in front of you. `verify` is the command that
|
||||
distinguishes "we have backups" from "we have backups that will restore", and
|
||||
the documentation recommends running it on a schedule.
|
||||
|
||||
The probe needs AES-256-GCM open, which today lives in
|
||||
`server/internal/services/crypto.go` and cannot be imported from another module.
|
||||
Rather than copy it — the exact hazard `CLAUDE.md` names around mirrored token
|
||||
blocks and `web/lib/targets.ts` — the primitives move to a new `shared/cryptobox`
|
||||
package, and `services/crypto.go` becomes a thin delegation that keeps its
|
||||
existing unexported function names and its `KEY_ENCRYPTION_KEY` lookup. One
|
||||
implementation of the cipher, two callers.
|
||||
|
||||
`--out -` streams the tarball to stdout, so piping into `aws s3 cp -`, `restic`
|
||||
or `age` covers storage and archive encryption without the tool growing backends
|
||||
of its own.
|
||||
|
||||
### Distribution
|
||||
|
||||
Three ways to run it, because the deployments that need it run Docker Compose,
|
||||
Kubernetes, or neither.
|
||||
|
||||
**Loose binary.** A new `.gitea/workflows/vantagectl-release.yml`, triggered on
|
||||
`vantagectl/v*` tags, shaped like `agent-release.yml`. Builds `linux/amd64`,
|
||||
`linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes
|
||||
`checksums.txt`, and creates a Gitea release.
|
||||
|
||||
**Container image.** `vantagectl/Dockerfile` — the repo's convention is a
|
||||
Dockerfile per module built from the repository root, because every Go module
|
||||
depends on `shared` through a replace directive — produces a `scratch`
|
||||
image holding the static binary and an explicitly copied `/tmp`, which the
|
||||
archive is staged in before compression — the same omission that silently
|
||||
disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an
|
||||
eighth image.
|
||||
|
||||
```bash
|
||||
docker run --rm --network vantage_default \
|
||||
-e MONGO_URI -e MONGO_DB -e KEY_ENCRYPTION_KEY \
|
||||
-v /backups:/out \
|
||||
gitea.hostxtra.co.uk/mrhid6/vantagectl backup --out /out
|
||||
```
|
||||
|
||||
**Kubernetes.** The chart gains `backup.enabled`, defaulting to **false**,
|
||||
rendering a `CronJob` that runs the same image and mounts the existing MongoDB
|
||||
and `KEY_ENCRYPTION_KEY` secrets by reference rather than re-declaring them.
|
||||
Output goes to a PVC named in values. The default is off because a backup with
|
||||
nowhere durable to land is a false sense of safety and the chart cannot know
|
||||
where that is; `NOTES.txt` says so on install.
|
||||
|
||||
Restore in Kubernetes is the same image run as a one-shot `Job`. The chart ships
|
||||
no restore manifest: a restore is an operator decision with a confirmation
|
||||
attached to it, and must never be something a `helm upgrade` can trigger.
|
||||
|
||||
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row —
|
||||
`vantagectl/`, `shared/`, `go.work` — which makes `shared/` fan out to four Go
|
||||
images rather than three. That table is already called out in `CLAUDE.md` as a
|
||||
place where a missed entry ships a stale image.
|
||||
|
||||
## Testing
|
||||
|
||||
`shared/backup` is tested against a real MongoDB, via `testcontainers-go` if the
|
||||
module graph tolerates it and otherwise behind a `MONGO_TEST_URI` environment
|
||||
variable that skips when unset.
|
||||
|
||||
Required cases:
|
||||
|
||||
- Round trip: seed one document of every awkward BSON type — `ObjectId`,
|
||||
`Decimal128`, `DateTime`, binary, null, nested arrays — back up, restore into
|
||||
a second database, assert byte-equal BSON.
|
||||
- A single corrupted byte in a `.bson` member causes restore to refuse before
|
||||
writing anything.
|
||||
- Fingerprint mismatch is refused; `--ignore-key-mismatch` proceeds and names
|
||||
the ciphertext-bearing collections.
|
||||
- A non-empty target is refused; `--force` replaces it.
|
||||
- An excluded collection is absent from the archive and named in the manifest.
|
||||
- A unique index that cannot be built aborts the restore, naming the index.
|
||||
|
||||
Fingerprint computation is a pure function and is tested without a database.
|
||||
|
||||
## Documentation
|
||||
|
||||
`docsite/docs/operations/backup-and-restore.md`, covering:
|
||||
|
||||
- What `KEY_ENCRYPTION_KEY` is, that it is not in the backup, and that losing it
|
||||
is unrecoverable. This comes first on the page, not as a note at the bottom.
|
||||
- The three run modes above, each as a command that can be copied.
|
||||
- A restore drill: restore into a scratch database and run `verify`, because an
|
||||
untested backup is a hypothesis.
|
||||
- What is not covered: Redis sessions, the vulnerability database (re-pulled
|
||||
automatically), and agent state on managed servers — agents reconnect on their
|
||||
own and `servers.agent_token_hash` is in the backup, so no re-enrolment is
|
||||
needed.
|
||||
|
||||
`CLAUDE.md` gains a section describing the tool, since a new module, a new
|
||||
image, a new workflow and a new chart toggle are each something that drifts
|
||||
quietly.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. Every decision above was settled during design.
|
||||
@@ -1,92 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package vantage.v1;
|
||||
|
||||
option go_package = "github.com/mrhid6/vantage/server/internal/grpc/pb";
|
||||
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
string server_id = 1;
|
||||
string pre_reg_token = 2;
|
||||
string hostname = 3;
|
||||
string ip_address = 4;
|
||||
string os_info = 5;
|
||||
}
|
||||
|
||||
message RegisterResponse {
|
||||
string agent_token = 1;
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string agent_version = 3;
|
||||
}
|
||||
|
||||
message SyncResponse {
|
||||
repeated string public_keys = 1;
|
||||
}
|
||||
|
||||
message UploadKeyRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string public_key = 3;
|
||||
string label = 4;
|
||||
string private_key = 5;
|
||||
}
|
||||
|
||||
message UploadKeyResponse {
|
||||
string key_id = 1;
|
||||
}
|
||||
|
||||
// CommandStream messages
|
||||
|
||||
message AgentMessage {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
oneof payload {
|
||||
AgentReady ready = 3;
|
||||
CommandResult result = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message AgentReady {}
|
||||
|
||||
message CommandResult {
|
||||
string command_id = 1;
|
||||
bool success = 2;
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
message ServerCommand {
|
||||
string command_id = 1;
|
||||
oneof command {
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
|
||||
message UpdateAgentCmd {
|
||||
string version = 1; // e.g. "1.2.3"
|
||||
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
|
||||
}
|
||||
|
||||
message GenerateKeyCmd {
|
||||
string label = 1;
|
||||
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
|
||||
int32 key_size = 3; // bits; used for rsa and ecdsa
|
||||
string passphrase = 4; // empty = no passphrase
|
||||
string comment = 5; // embedded in public key
|
||||
}
|
||||
+30
-7
@@ -1,24 +1,47 @@
|
||||
# Build stage
|
||||
#
|
||||
# Context is still the repository root, not server/, because the runtime stage
|
||||
# copies default_steps/ from it.
|
||||
#
|
||||
# vantage-shared is a private module, so every step that resolves it needs a
|
||||
# credential. It arrives as a BuildKit secret rather than a build arg: an arg
|
||||
# is baked into the builder layer's history, and this one is a Gitea token.
|
||||
FROM golang:1.26 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /src
|
||||
|
||||
# Download dependencies first (layer cache)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
ENV GOPRIVATE=gitea.hostxtra.co.uk/*
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
# Manifests first so the dependency layer caches independently of source edits.
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
cd server && go mod download
|
||||
|
||||
COPY server/ ./server/
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
# itself — scratch has no shell — and os.MkdirTemp fails outright without it.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
# vulndb unpacks the ~50MB trivy-db here. Without it the scheduler stops at
|
||||
# "temp dir: stat /tmp: no such file or directory" and no scanning happens,
|
||||
# while everything else in the process runs perfectly well.
|
||||
COPY --from=builder /staging/tmp /tmp
|
||||
COPY --from=builder /vantage-server /vantage-server
|
||||
|
||||
COPY default_steps/ /opt/default-steps/
|
||||
ENV VANTAGE_DEFAULT_STEPS_DIR=/opt/default-steps
|
||||
|
||||
EXPOSE 8080 9090
|
||||
|
||||
ENTRYPOINT ["/vantage-server"]
|
||||
|
||||
+288
-26
@@ -2,66 +2,317 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// Embeds the IANA zone database in the binary. Load-bearing: server/Dockerfile
|
||||
// builds on Alpine, which ships no zoneinfo, so without this
|
||||
// time.LoadLocation("Europe/London") fails in production and every workflow
|
||||
// schedule silently falls back to UTC.
|
||||
_ "time/tzdata"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/api"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
// @title Vantage API
|
||||
// @version 1.0
|
||||
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
|
||||
// @BasePath /api
|
||||
|
||||
// Each @securityDefinitions.apikey block below is deliberately its own
|
||||
// comment group, separated by a real blank line rather than a bare "//": Go's
|
||||
// parser only splits ast.CommentGroups on an actual blank line, and
|
||||
// swag v2.0.0-rc5's parseSecAttributesV3 resolves a scheme's map key by
|
||||
// scanning from the start of whatever comment group it was handed — so three
|
||||
// stacked blocks sharing one group all collapse onto the first block's name.
|
||||
// Three groups means three independent scans, each finding its own name.
|
||||
|
||||
// @securityDefinitions.apikey cookieAuth
|
||||
// @in cookie
|
||||
// @name km_session
|
||||
|
||||
// @securityDefinitions.apikey bearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
|
||||
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
dbName := getEnv("MONGO_DB", "vantage")
|
||||
|
||||
if err := db.Connect(mongoURI, dbName); err != nil {
|
||||
// Two flags, so the schema work can be lifted out of the serving pods.
|
||||
//
|
||||
// Under Docker Compose neither is set and nothing changes: one process
|
||||
// migrates and then serves. Under Kubernetes with more than one replica
|
||||
// that is unsafe — every pod would run MigrateOrgToInstance at once, and
|
||||
// renaming collections while a sibling reads them is not a race anyone
|
||||
// wins. The chart therefore runs a pre-upgrade Job with MIGRATE_ONLY and
|
||||
// starts the Deployment with SKIP_MIGRATIONS.
|
||||
migrateOnly := boolEnv("VANTAGE_MIGRATE_ONLY")
|
||||
skipMigrations := boolEnv("VANTAGE_SKIP_MIGRATIONS")
|
||||
|
||||
// Not required in migrate-only mode: that process never serves gRPC, and
|
||||
// demanding it would put an agent-facing address in a Job that has no
|
||||
// business knowing one.
|
||||
if !migrateOnly && os.Getenv("GRPC_HOST") == "" {
|
||||
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
|
||||
}
|
||||
|
||||
// DB name comes from the MONGO_URI path; "vantage" is the fallback.
|
||||
if err := db.Connect(mongoURI, "vantage"); err != nil {
|
||||
log.Fatalf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
log.Println("connected to MongoDB")
|
||||
|
||||
if migrateOnly || !skipMigrations {
|
||||
runSchemaSetup()
|
||||
} else {
|
||||
log.Println("VANTAGE_SKIP_MIGRATIONS set: assuming migrations ran elsewhere")
|
||||
}
|
||||
|
||||
if migrateOnly {
|
||||
log.Println("VANTAGE_MIGRATE_ONLY set: schema setup complete, exiting")
|
||||
return
|
||||
}
|
||||
|
||||
serve()
|
||||
}
|
||||
|
||||
// runSchemaSetup performs every write that must happen exactly once before the
|
||||
// application serves: migrations, index builders and default step seeding. It
|
||||
// is fatal on anything that would leave the schema half-moved.
|
||||
func runSchemaSetup() {
|
||||
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
|
||||
// so they must run before 0004 renames everything underneath them.
|
||||
if err := services.RunMigrations(); err != nil {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
// 0002 must precede 0003: 0003 can create a "default" org, which pushes
|
||||
// 0002 into its ambiguous multi-org branch.
|
||||
if err := services.MigrateSettingsOrg(); err != nil {
|
||||
log.Fatalf("settings org migration failed: %v", err)
|
||||
}
|
||||
if err := services.MigrateMissedOrgScopes(); err != nil {
|
||||
log.Fatalf("missed org scope migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 0004 renames orgs to instances. It must run BEFORE the index builders:
|
||||
// EnsureAuthIndexes creates instances.slug, which would create an empty
|
||||
// instances collection and make 0004 refuse to rename onto it.
|
||||
migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
migErr := services.MigrateOrgToInstance(migCtx, db.Database)
|
||||
migCancel()
|
||||
if migErr != nil {
|
||||
log.Fatalf("instance rename migration failed: %v", migErr)
|
||||
}
|
||||
|
||||
assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
|
||||
assertCancel()
|
||||
if assertErr != nil {
|
||||
log.Fatalf("scoped collection check failed: %v", assertErr)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuthIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAPITokenIndexes(); err != nil {
|
||||
log.Fatalf("api token indexes: %v", err)
|
||||
}
|
||||
|
||||
// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
|
||||
// index must exist before anything inserts providers, or a concurrent
|
||||
// re-run could double-insert before the index is there to refuse it.
|
||||
if err := services.MigrateAuthProviders(); err != nil {
|
||||
log.Fatalf("auth provider migration failed: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureSecretIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure secret indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureServerIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure server indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureSettingsIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure settings indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureWorkflowIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure workflow indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureMonitorSampleIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureVulnIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure vuln indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureWorkloadIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure workload indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureStatusPageIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure status page indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuditIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure audit indexes: %v", err)
|
||||
}
|
||||
|
||||
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
|
||||
log.Printf("warning: failed to list instances for default step seeding: %v", err)
|
||||
} else {
|
||||
for _, instanceID := range instanceIDs {
|
||||
if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
|
||||
} else {
|
||||
log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func serve() {
|
||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||
if err := auth.InitRedis(redisAddr); err != nil {
|
||||
redisUser := os.Getenv("REDIS_USERNAME")
|
||||
redisPass := os.Getenv("REDIS_PASSWORD")
|
||||
if err := auth.InitRedis(redisAddr, redisUser, redisPass); err != nil {
|
||||
log.Fatalf("failed to connect to Redis: %v", err)
|
||||
}
|
||||
log.Println("connected to Redis")
|
||||
|
||||
if err := auth.InitOIDC(context.Background()); err != nil {
|
||||
log.Fatalf("failed to initialise OIDC: %v", err)
|
||||
// The bus carries agent commands and step results between replicas. It is
|
||||
// not optional even on a single-replica deployment: dispatch takes the same
|
||||
// path either way, so the code exercised in production is the code
|
||||
// exercised everywhere.
|
||||
if err := bus.Init(redisAddr, redisUser, redisPass); err != nil {
|
||||
log.Fatalf("failed to connect the message bus: %v", err)
|
||||
}
|
||||
log.Printf("message bus ready as node %s", bus.NodeID())
|
||||
|
||||
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context — the
|
||||
// housekeeping jobs, the leader lock — stops when the pod is asked to.
|
||||
ctx, shutdown := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer shutdown()
|
||||
|
||||
stopGRPC, err := grpcserver.StartGRPC(9090)
|
||||
if err != nil {
|
||||
log.Fatalf("gRPC server error: %v", err)
|
||||
}
|
||||
|
||||
// Background goroutine to mark offline servers
|
||||
go func() {
|
||||
// Everything below runs on exactly one replica at a time.
|
||||
//
|
||||
// These are cluster-singleton jobs, not per-pod work: N replicas would mean
|
||||
// every monitor check firing N times, every incident notification delivered
|
||||
// to the customer N times, every retention sweep deleting concurrently, and
|
||||
// N reapers racing to purge the same instance. They share one lock rather
|
||||
// than holding four, because they are one role — housekeeping — and
|
||||
// splitting them would only spread that role across pods for no benefit.
|
||||
bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {
|
||||
services.StartLogSweeper(jobCtx)
|
||||
services.StartAuditSweeper(jobCtx)
|
||||
services.StartReaper(jobCtx)
|
||||
monitorsched.Start(jobCtx)
|
||||
workflowsched.Start(jobCtx, workflowsched.Deps{
|
||||
TriggerWorkflow: services.TriggerWorkflow,
|
||||
LogEvent: services.LogEvent,
|
||||
})
|
||||
|
||||
vulnsched.Start(jobCtx, vulnsched.Deps{
|
||||
LogEvent: services.LogEvent,
|
||||
SendDigest: services.SendVulnDigest,
|
||||
})
|
||||
services.StartVulnSweeper(jobCtx)
|
||||
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := services.MarkOfflineServers(5 * time.Minute); err != nil {
|
||||
log.Printf("mark offline error: %v", err)
|
||||
for {
|
||||
select {
|
||||
case <-jobCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := services.MarkOfflineServers(); err != nil {
|
||||
log.Printf("mark offline error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// Start gRPC server
|
||||
r := gin.New()
|
||||
// Without this gin trusts every proxy and ClientIP() is whatever the
|
||||
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
|
||||
// only produced audit strings; the public status limiter makes it load
|
||||
// bearing. Empty means trust nobody, which is correct for a direct
|
||||
// exposure and wrong behind a proxy — hence the explicit setting.
|
||||
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
|
||||
log.Fatalf("trusted proxies: %v", err)
|
||||
}
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
r.Use(corsMiddleware())
|
||||
services.SetStatusRedis(auth.Redis())
|
||||
|
||||
api.RegisterRoutes(r)
|
||||
|
||||
if err := api.AssertScopeMapComplete(r); err != nil {
|
||||
log.Fatalf("api scope map: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Addr: ":8080", Handler: r}
|
||||
go func() {
|
||||
if err := grpcserver.StartGRPC(9090); err != nil {
|
||||
log.Fatalf("gRPC server error: %v", err)
|
||||
log.Println("REST server listening on :8080")
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("REST server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start REST server
|
||||
r := gin.Default()
|
||||
r.Use(corsMiddleware())
|
||||
api.RegisterRoutes(r)
|
||||
<-ctx.Done()
|
||||
log.Println("shutdown signal received")
|
||||
|
||||
log.Println("REST server listening on :8080")
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("REST server error: %v", err)
|
||||
// gRPC first, and this ordering is the point of the whole exercise. Stopping
|
||||
// it runs each CommandStream handler's deferred release, which clears that
|
||||
// agent's presence claim; until that happens another replica will keep
|
||||
// dispatching commands to this process. Draining HTTP first would leave the
|
||||
// claims held for the length of the drain.
|
||||
stopGRPC()
|
||||
|
||||
drainCtx, cancelDrain := context.WithTimeout(context.Background(), httpDrainTimeout)
|
||||
defer cancelDrain()
|
||||
if err := srv.Shutdown(drainCtx); err != nil {
|
||||
log.Printf("REST server shutdown: %v", err)
|
||||
}
|
||||
log.Println("shutdown complete")
|
||||
}
|
||||
|
||||
// How long in-flight REST requests are given to finish. Console tunnels are
|
||||
// long-lived WebSockets that will not end on their own, so this is a ceiling
|
||||
// rather than a target; the relays behind them are already gone by this point.
|
||||
const httpDrainTimeout = 10 * time.Second
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
@@ -81,3 +332,14 @@ func getEnv(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// boolEnv reads a flag env var. Anything other than a recognised truthy value
|
||||
// is false, so a typo leaves the safe default (migrate here, serve here) rather
|
||||
// than silently skipping schema setup.
|
||||
func boolEnv(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+36
-10
@@ -1,18 +1,43 @@
|
||||
module github.com/mrhid6/vantage/server
|
||||
module gitea.hostxtra.co.uk/mrhid6/vantage/server
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
oras.land/oras-go/v2 v2.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/samber/lo v1.50.0 // indirect
|
||||
github.com/samber/oops v1.18.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opentelemetry.io/otel v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.34.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -25,28 +50,29 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/sirupsen/logrus v1.4.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
+76
-20
@@ -1,3 +1,9 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -35,21 +41,36 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GXhHq+7LeOzx/haG7HSIZokl3/0GkoUFzsRJjg=
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8=
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 h1:dWzdsqjh1p2gNtRKqNwuBvKqMNwnLOPLzVZT1n6DK7s=
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23/go.mod h1:lUaIXCWzf7BRKTY5iEcrYy1TfgbYLYVIS/B2vPkJzOc=
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f h1:xt29M2T6STgldg+WEP51gGePQCsQvklmP2eIhPIBK3g=
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:i4sF0l1fFnY1aiw08QQSwVAFxHEm311Me3WsU/X7nL0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -59,32 +80,56 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
|
||||
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
|
||||
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
|
||||
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
|
||||
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
@@ -92,8 +137,14 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@@ -101,36 +152,37 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -141,10 +193,14 @@ google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
|
||||
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type authProviderView struct {
|
||||
models.AuthProvider
|
||||
ClientSecretSet bool `json:"client_secret_set"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
|
||||
func viewOf(c *gin.Context, p models.AuthProvider) authProviderView {
|
||||
return authProviderView{
|
||||
AuthProvider: p,
|
||||
ClientSecretSet: p.ClientSecretEnc != "",
|
||||
CallbackURL: auth.CallbackURL(c, p.ProviderID),
|
||||
}
|
||||
}
|
||||
|
||||
// listAuthPresets godoc
|
||||
//
|
||||
// @Summary List SSO presets
|
||||
// @Description Preset providers (Entra, Google, Okta, GitHub) that expand to a real issuer on save.
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Success 200 {array} auth.Preset
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/presets [get]
|
||||
func listAuthPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, auth.Presets())
|
||||
}
|
||||
|
||||
// listAuthProviders godoc
|
||||
//
|
||||
// @Summary List SSO providers
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Success 200 {array} authProviderView
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers [get]
|
||||
func listAuthProviders(c *gin.Context) {
|
||||
providers, err := services.ListAuthProviders(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]authProviderView, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
out = append(out, viewOf(c, p))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createAuthProvider godoc
|
||||
//
|
||||
// @Summary Create an SSO provider
|
||||
// @Tags auth-providers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{name=string,preset=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool} true "Provider parameters"
|
||||
// @Success 201 {object} authProviderView
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers [post]
|
||||
func createAuthProvider(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Preset string `json:"preset"`
|
||||
IssuerInput string `json:"issuer_input"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
issuer, err := auth.ExpandIssuer(body.Preset, body.IssuerInput)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instanceID := auth.InstanceID(c)
|
||||
p, err := services.CreateAuthProvider(&models.AuthProvider{
|
||||
InstanceID: instanceID,
|
||||
Name: body.Name,
|
||||
Kind: auth.KindFor(body.Preset),
|
||||
Preset: body.Preset,
|
||||
Issuer: issuer,
|
||||
ClientID: body.ClientID,
|
||||
Scopes: auth.DefaultScopes(body.Preset),
|
||||
Enabled: body.Enabled,
|
||||
}, body.ClientSecret)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "auth_provider.create", actorFromCtx(c), "", "", p.Name)
|
||||
c.JSON(http.StatusCreated, viewOf(c, *p))
|
||||
}
|
||||
|
||||
// updateAuthProvider godoc
|
||||
//
|
||||
// @Summary Update an SSO provider
|
||||
// @Tags auth-providers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Param body body object{name=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool,order=int} true "Fields to update"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id} [put]
|
||||
func updateAuthProvider(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
IssuerInput *string `json:"issuer_input"`
|
||||
ClientID *string `json:"client_id"`
|
||||
ClientSecret *string `json:"client_secret"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Order *int `json:"order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
|
||||
existing, err := services.GetAuthProvider(instanceID, providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := guardProviderChange(instanceID, existing, body.Enabled, false); err != nil {
|
||||
if errors.Is(err, services.ErrLockout) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "last_provider"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
in := services.AuthProviderUpdate{
|
||||
Name: body.Name, ClientID: body.ClientID,
|
||||
ClientSecret: body.ClientSecret, Enabled: body.Enabled, Order: body.Order,
|
||||
}
|
||||
if body.IssuerInput != nil {
|
||||
issuer, err := auth.ExpandIssuer(existing.Preset, *body.IssuerInput)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
in.Issuer = &issuer
|
||||
}
|
||||
if err := services.UpdateAuthProvider(instanceID, providerID, in); err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Issuer, client ID or secret may have changed; the cached discovery
|
||||
// document was built from the old ones.
|
||||
auth.EvictProvider(providerID)
|
||||
services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// deleteAuthProvider godoc
|
||||
//
|
||||
// @Summary Delete an SSO provider
|
||||
// @Description Refused when the instance would be left with no way in (no local login and no other enabled provider).
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id} [delete]
|
||||
func deleteAuthProvider(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
|
||||
existing, err := services.GetAuthProvider(instanceID, providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
if err := guardProviderChange(instanceID, existing, nil, true); err != nil {
|
||||
if errors.Is(err, services.ErrLockout) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "last_provider"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.DeleteAuthProvider(instanceID, providerID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
auth.EvictProvider(providerID)
|
||||
services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// guardProviderChange asks whether the instance would still have a way in.
|
||||
// Deleting and disabling reach the same condition, so they share one answer.
|
||||
func guardProviderChange(instanceID string, existing *models.AuthProvider, enabled *bool, deleting bool) error {
|
||||
losing := deleting || (enabled != nil && !*enabled)
|
||||
if !losing || !existing.Enabled {
|
||||
return nil
|
||||
}
|
||||
n, err := services.CountEnabledAuthProviders(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1)
|
||||
}
|
||||
|
||||
// ackAuthProviderNotice godoc
|
||||
//
|
||||
// @Summary Acknowledge a provider migration notice
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} AcknowledgedResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id}/ack-notice [post]
|
||||
func ackAuthProviderNotice(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
existing, err := services.GetAuthProvider(instanceID, providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
if err := services.AckAuthProviderNotice(instanceID, providerID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "auth_provider.ack_notice", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, AcknowledgedResponse{Acknowledged: true})
|
||||
}
|
||||
|
||||
// testAuthProvider godoc
|
||||
//
|
||||
// @Summary Test an SSO provider's reachability
|
||||
// @Description Proves the configuration is reachable. It signs nobody in.
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} TestProviderResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id}/test [post]
|
||||
func testAuthProvider(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
p, err := services.GetAuthProvider(instanceID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
if p.Kind == models.KindOAuth2 {
|
||||
// GitHub has no discovery document. The only meaningful check without
|
||||
// a user token is that credentials are present.
|
||||
if p.ClientID == "" || p.ClientSecretEnc == "" {
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: "client ID and secret are required"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "credentials are configured"})
|
||||
return
|
||||
}
|
||||
if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil {
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "discovery document fetched"})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func registerChannelRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/channels", listChannels)
|
||||
g.POST("/channels", createChannel)
|
||||
g.PUT("/channels/:id", updateChannel)
|
||||
g.DELETE("/channels/:id", deleteChannel)
|
||||
g.POST("/channels/:id/test", testChannel)
|
||||
}
|
||||
|
||||
// listChannels godoc
|
||||
//
|
||||
// @Summary List notification channels
|
||||
// @Tags channels
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.NotificationChannel
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels [get]
|
||||
func listChannels(c *gin.Context) {
|
||||
channels, err := services.ListChannels(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Redacted here rather than in the service: the dispatchers read the same
|
||||
// documents and need the real credentials, so the masking belongs to the
|
||||
// boundary that hands them to a client.
|
||||
out := make([]models.NotificationChannel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, ch.Redacted())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createChannel godoc
|
||||
//
|
||||
// @Summary Create a notification channel
|
||||
// @Tags channels
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.NotificationChannel true "Channel to create"
|
||||
// @Success 201 {object} models.NotificationChannel
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels [post]
|
||||
func createChannel(c *gin.Context) {
|
||||
var ch models.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ch.Name == "" || ch.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateChannel(auth.InstanceID(c), &ch)
|
||||
if err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created.Redacted())
|
||||
}
|
||||
|
||||
// updateChannel godoc
|
||||
//
|
||||
// @Summary Update a notification channel
|
||||
// @Tags channels
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Param body body object{name=string,type=string,config=map[string]string,enabled=bool} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id} [put]
|
||||
func updateChannel(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Config *map[string]string `json:"config"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
upd := bson.M{}
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
if body.Config != nil {
|
||||
upd["config"] = *body.Config
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
upd["enabled"] = *body.Enabled
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateChannel(auth.InstanceID(c), c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteChannel godoc
|
||||
//
|
||||
// @Summary Delete a notification channel
|
||||
// @Tags channels
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Success 204
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id} [delete]
|
||||
func deleteChannel(c *gin.Context) {
|
||||
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// testChannel godoc
|
||||
//
|
||||
// @Summary Send a test notification
|
||||
// @Tags channels
|
||||
// @Produce json
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id}/test [post]
|
||||
func testChannel(c *gin.Context) {
|
||||
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "sent"})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wwt/guac"
|
||||
)
|
||||
|
||||
// consoleConnect godoc
|
||||
//
|
||||
// @Summary Open a browser console session
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.
|
||||
// @Tags console
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{server_id=string,protocol=string,key_id=string,rdp_username=string,rdp_password=string,ssh_username=string} true "Session parameters"
|
||||
// @Success 200 {object} ConsoleConnectResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /console/connect [post]
|
||||
func consoleConnect(c *gin.Context) {
|
||||
var body struct {
|
||||
ServerID string `json:"server_id" binding:"required"`
|
||||
Protocol string `json:"protocol" binding:"required"`
|
||||
KeyID string `json:"key_id"`
|
||||
RDPUsername string `json:"rdp_username"`
|
||||
RDPPassword string `json:"rdp_password"`
|
||||
SSHUsername string `json:"ssh_username"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(auth.InstanceID(c), body.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if srv.Status != "active" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "agent_offline",
|
||||
"message": "The agent on this server is not connected. " +
|
||||
"Console sessions are relayed by the agent, so it must be online.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := services.CreateConsoleSession(auth.InstanceID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, err := services.SignSessionToken(sess.SessionID, time.Minute)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
|
||||
if err := services.StashConsoleRDPCreds(auth.InstanceID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if body.Protocol == "ssh" {
|
||||
if err := services.SetConsoleSSHUser(auth.InstanceID(c), sess.SessionID, body.SSHUsername); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+", agent-relayed)")
|
||||
|
||||
c.JSON(http.StatusOK, ConsoleConnectResponse{
|
||||
SessionID: sess.SessionID,
|
||||
Token: token,
|
||||
WSPath: "/api/console/tunnel",
|
||||
})
|
||||
}
|
||||
|
||||
func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
v, err := strconv.Atoi(r.URL.Query().Get(key))
|
||||
if err != nil || v <= 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// consoleTunnel upgrades the browser's WebSocket and joins it to guacd.
|
||||
//
|
||||
// Every branch here logs. That is deliberate and worth keeping: this handler
|
||||
// spans four hops (session store, agent dispatch, relay announcement, guacd),
|
||||
// any of which can fail, and the client is told the same near-useless thing by
|
||||
// most of them — a 500 that guacamole then reports as an *upstream* error,
|
||||
// naming the wrong hop entirely. Without a line per branch the only evidence a
|
||||
// failure leaves is a GIN status code, and with several replicas you cannot
|
||||
// even tell which process produced it.
|
||||
//
|
||||
// Lines are prefixed with the session ID so one attempt can be followed across
|
||||
// pods, and the pod's own hostname so it is obvious which one served it.
|
||||
// consoleTunnel godoc
|
||||
//
|
||||
// @Summary Console websocket tunnel
|
||||
// @Description Upgrades the browser's connection to a websocket and joins it to guacd, relayed through the agent. Consumes the one-time session token from /console/connect.
|
||||
// @Tags console
|
||||
// @Param token query string true "One-time session token"
|
||||
// @Success 101
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /console/tunnel [get]
|
||||
func consoleTunnel(c *gin.Context) {
|
||||
host, _ := os.Hostname()
|
||||
|
||||
token := c.Query("token")
|
||||
sessionID, err := services.VerifySessionToken(token)
|
||||
if err != nil {
|
||||
log.Printf("console[%s]: reject: invalid session token: %v", host, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Bound to the session from here on, so every later line correlates.
|
||||
tlog := func(format string, args ...any) {
|
||||
log.Printf("console[%s %s]: "+format, append([]any{host, sessionID}, args...)...)
|
||||
}
|
||||
tlog("tunnel opened by %s", actorFromCtx(c))
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
sess, err := services.GetConsoleSession(instanceID, sessionID)
|
||||
if err != nil {
|
||||
tlog("reject: console session not found: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if actor := actorFromCtx(c); actor != sess.User {
|
||||
tlog("reject: session belongs to %s, not %s", sess.User, actor)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
|
||||
tlog("reject: token already consumed: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
|
||||
if err != nil {
|
||||
tlog("reject: server %s not found: %v", sess.ServerID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
tlog("server %s (%s), protocol %s", srv.ServerID, srv.Hostname, sess.Protocol)
|
||||
|
||||
var privKey, passphrase string
|
||||
if sess.Protocol == "ssh" && sess.KeyID != "" {
|
||||
privKey, err = services.GetPrivateKey(auth.InstanceID(c), sess.KeyID)
|
||||
if err != nil {
|
||||
tlog("reject: key %s has no private material: %v", sess.KeyID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
|
||||
return
|
||||
}
|
||||
passphrase, _ = services.GetPassphrase(sess.KeyID)
|
||||
tlog("ssh key %s loaded (passphrase=%t)", sess.KeyID, passphrase != "")
|
||||
}
|
||||
|
||||
var rdpUser, rdpPass string
|
||||
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
|
||||
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(instanceID, sessionID)
|
||||
if err != nil {
|
||||
tlog("reject: could not consume %s credentials: %v", sess.Protocol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
|
||||
return
|
||||
}
|
||||
tlog("%s credentials consumed (user=%t)", sess.Protocol, rdpUser != "")
|
||||
}
|
||||
|
||||
targetPort, err := services.TargetPort(srv, sess.Protocol)
|
||||
if err != nil {
|
||||
tlog("reject: no target port for %s: %v", sess.Protocol, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tlog("opening relay to %s:%d", srv.ServerID, targetPort)
|
||||
|
||||
relay, err := services.OpenConsoleProxy(instanceID, srv.ServerID, targetPort)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAgentOffline) {
|
||||
tlog("reject: agent offline")
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "agent_offline"})
|
||||
return
|
||||
}
|
||||
// The client is deliberately told nothing specific, so this is the only
|
||||
// place the real reason exists — a failed dispatch and a relay that was
|
||||
// never announced are the same generic 500 to the browser.
|
||||
tlog("reject: open relay: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"})
|
||||
return
|
||||
}
|
||||
tlog("relay %s ready at %s:%d", relay.ProxyID, relay.Host, relay.Port)
|
||||
// guac.WebsocketServer.ServeHTTP returns before installing its
|
||||
// OnDisconnect handler when the connect callback errors, which is exactly
|
||||
// the path every relay failure this proxy introduces takes (the agent
|
||||
// never claims it, dial_refused, guacd never dials, rejected). Emitting
|
||||
// console.proxy_failed and ending the session here, unconditionally on
|
||||
// teardown, is what makes those failures reach the audit log at all;
|
||||
// OnDisconnect below only sees the rarer case of a session that was fully
|
||||
// established and then failed.
|
||||
defer func() {
|
||||
relay.Close()
|
||||
if reason := relay.Reason(); reason != "" {
|
||||
tlog("relay %s ended: %s", relay.ProxyID, reason)
|
||||
services.LogEvent(instanceID, "console.proxy_failed", actorFromCtx(c), srv.ServerID, "",
|
||||
fmt.Sprintf("console relay failed: %s (proxy_id=%s, port=%d)", reason, relay.ProxyID, relay.Port))
|
||||
} else {
|
||||
tlog("relay %s closed cleanly", relay.ProxyID)
|
||||
}
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
tlog("tunnel finished")
|
||||
}()
|
||||
|
||||
services.LogEvent(instanceID, "console.proxy_opened", actorFromCtx(c), srv.ServerID, "",
|
||||
fmt.Sprintf("console relay opened (proxy_id=%s, port=%d)", relay.ProxyID, relay.Port))
|
||||
|
||||
gp, err := services.BuildGuacParams(sess.Protocol, sess.SSHUsername, privKey, passphrase,
|
||||
rdpUser, rdpPass, relay.Host, relay.Port)
|
||||
if err != nil {
|
||||
tlog("reject: build guacd parameters: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
guacdAddr := os.Getenv("GUACD_ADDR")
|
||||
if guacdAddr == "" {
|
||||
guacdAddr = "guacd:4822"
|
||||
}
|
||||
|
||||
connect := func(r *http.Request) (guac.Tunnel, error) {
|
||||
config := guac.NewGuacamoleConfiguration()
|
||||
config.Protocol = gp.Protocol
|
||||
for k, v := range gp.Params {
|
||||
config.Parameters[k] = v
|
||||
}
|
||||
config.OptimalScreenWidth = queryIntDefault(r, "width", 1024)
|
||||
config.OptimalScreenHeight = queryIntDefault(r, "height", 768)
|
||||
config.OptimalResolution = queryIntDefault(r, "dpi", 96)
|
||||
|
||||
// Resolution is logged separately from the dial: a headless guacd
|
||||
// Service returns pod addresses, and which one was picked is the
|
||||
// difference between "guacd refused" and "we called the wrong guacd".
|
||||
addr, err := net.ResolveTCPAddr("tcp", guacdAddr)
|
||||
if err != nil {
|
||||
tlog("guacd: resolve %s: %v", guacdAddr, err)
|
||||
return nil, err
|
||||
}
|
||||
tlog("guacd: dialling %s (%s)", guacdAddr, addr.String())
|
||||
|
||||
conn, err := net.DialTCP("tcp", nil, addr)
|
||||
if err != nil {
|
||||
tlog("guacd: dial %s: %v", addr.String(), err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The handshake is where guacd connects onward to the relay, so a
|
||||
// failure here is guacd reporting it could not reach %s:%d — the hop
|
||||
// that has been hardest to see from either end.
|
||||
stream := guac.NewStream(conn, guac.SocketTimeout)
|
||||
if err := stream.Handshake(config); err != nil {
|
||||
tlog("guacd: handshake for %s to relay %s:%d: %v",
|
||||
gp.Protocol, relay.Host, relay.Port, err)
|
||||
return nil, err
|
||||
}
|
||||
tlog("guacd: tunnel established (%s %dx%d)",
|
||||
gp.Protocol, config.OptimalScreenWidth, config.OptimalScreenHeight)
|
||||
return guac.NewSimpleTunnel(stream), nil
|
||||
}
|
||||
|
||||
// Teardown (proxy_failed audit + EndConsoleSession) lives in the deferred
|
||||
// func above, not here: this only fires once a tunnel was actually
|
||||
// established, and letting both paths log would double the audit event.
|
||||
wsServer := guac.NewWebsocketServer(connect)
|
||||
tlog("serving websocket")
|
||||
wsServer.ServeHTTP(c.Writer, c.Request)
|
||||
tlog("websocket returned")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package docs holds the generated OpenAPI document and the vendored Scalar
|
||||
// bundle that renders it.
|
||||
//
|
||||
// openapi.json is generated by `swag init` and committed rather than built into
|
||||
// the image: server/Dockerfile produces a scratch runtime from a Go build
|
||||
// stage, and adding codegen there means putting the toolchain in the image.
|
||||
// server-deploy.yml regenerates and diffs it, so an annotation edited without
|
||||
// regenerating fails the build.
|
||||
//
|
||||
// scalar.standalone.js is vendored from
|
||||
// https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js
|
||||
// and refreshed by hand. Fetched at build time it would break an air-gapped
|
||||
// install; fetched at page load it would break an air-gapped install more
|
||||
// visibly.
|
||||
package docs
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed openapi.json
|
||||
var OpenAPI []byte
|
||||
|
||||
//go:embed scalar.standalone.js
|
||||
var ScalarJS []byte
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+618
-85
@@ -1,31 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func actorFromCtx(c *gin.Context) string {
|
||||
sess := auth.GetSessionFromContext(c)
|
||||
if sess == nil || sess.Email == "" {
|
||||
return "admin"
|
||||
}
|
||||
// The actor stays the human, because a token acts on their behalf and the
|
||||
// log has to name somebody. The credential is appended so a person clicking
|
||||
// and their CI job are told apart.
|
||||
if sess.TokenID != "" {
|
||||
return fmt.Sprintf("%s (via token:%s)", sess.Email, sess.TokenName)
|
||||
}
|
||||
return sess.Email
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/healthz", handleHealthz)
|
||||
r.GET("/readyz", handleReadyz)
|
||||
|
||||
r.GET("/install", handleInstallScript)
|
||||
r.GET("/install.ps1", handleInstallScriptWindows)
|
||||
r.GET("/update", handleUpdateScript)
|
||||
r.GET("/update.ps1", handleUpdateScriptWindows)
|
||||
|
||||
// Auth endpoints (no session required)
|
||||
r.GET("/auth/login", auth.HandleLogin)
|
||||
r.GET("/auth/callback", auth.HandleCallback)
|
||||
r.GET("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
|
||||
|
||||
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
|
||||
r.POST("/auth/bootstrap", auth.HandleBootstrap)
|
||||
r.POST("/auth/login", auth.HandleLocalLogin)
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/me", auth.HandleMe)
|
||||
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
|
||||
r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback)
|
||||
r.GET("/auth/providers", auth.HandleListPublicProviders)
|
||||
|
||||
// Completely public: no session, no token, no licence gate. Mounted here
|
||||
// rather than under /api precisely so that none of those apply.
|
||||
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
|
||||
|
||||
// API endpoints protected by session middleware
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Scope enforcement sits between authentication and the licence gate, and
|
||||
// no-ops for cookie sessions. It is mounted here rather than per route so
|
||||
// a route added later is covered by where it lives, not by memory.
|
||||
apiGroup.Use(RequireScopes())
|
||||
apiGroup.Use(RateLimitTokens())
|
||||
// Deny by default: every non-GET route under /api is gated unless it is on
|
||||
// the exemption list in licence.go. A route added later is covered because
|
||||
// of where it is mounted, not because someone remembered.
|
||||
apiGroup.Use(RequireActiveLicense())
|
||||
{
|
||||
apiGroup.GET("/license", getLicence)
|
||||
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
|
||||
|
||||
apiGroup.GET("/servers", listServers)
|
||||
// Static segment, registered alongside /servers/:id exactly as
|
||||
// /servers/new already is — gin resolves statics ahead of wildcards.
|
||||
apiGroup.GET("/servers/tags", listKnownTags)
|
||||
apiGroup.POST("/servers", createServer)
|
||||
apiGroup.GET("/servers/new", newServer)
|
||||
apiGroup.POST("/servers/new", newServer)
|
||||
@@ -33,9 +77,38 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.DELETE("/servers/:id", deleteServer)
|
||||
apiGroup.POST("/servers/:id/generate-key", generateKey)
|
||||
apiGroup.POST("/servers/:id/update-agent", updateAgent)
|
||||
apiGroup.POST("/servers/:id/apply-updates", applyUpdates)
|
||||
apiGroup.PUT("/servers/:id/tags", putServerTags)
|
||||
|
||||
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
|
||||
|
||||
apiGroup.GET("/audit", listAuditEvents)
|
||||
|
||||
apiGroup.GET("/tokens", listTokens)
|
||||
apiGroup.GET("/tokens/scopes", listTokenScopes)
|
||||
apiGroup.POST("/tokens", createToken)
|
||||
apiGroup.DELETE("/tokens/:id", revokeToken)
|
||||
|
||||
apiGroup.GET("/openapi.json", getOpenAPI)
|
||||
apiGroup.GET("/docs", getAPIDocs)
|
||||
apiGroup.GET("/docs/scalar.js", getScalarJS)
|
||||
|
||||
settings := apiGroup.Group("/settings")
|
||||
settings.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
settings.GET("", getSettings)
|
||||
settings.PUT("", saveSettings)
|
||||
settings.POST("/secrets-token", rotateSecretsToken)
|
||||
}
|
||||
|
||||
apiGroup.GET("/secrets", listSecretGroups)
|
||||
apiGroup.POST("/secrets", createSecretGroup)
|
||||
apiGroup.GET("/secrets/:group", getSecretGroup)
|
||||
apiGroup.PUT("/secrets/:group", putSecretGroup)
|
||||
apiGroup.POST("/secrets/:group/reveal", revealSecret)
|
||||
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
|
||||
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
|
||||
|
||||
apiGroup.GET("/keys", listKeys)
|
||||
apiGroup.POST("/keys", createKey)
|
||||
apiGroup.GET("/keys/:id", getKey)
|
||||
@@ -43,11 +116,81 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.DELETE("/keys/:id", deleteKey)
|
||||
apiGroup.POST("/keys/:id/assign", assignKey)
|
||||
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||
|
||||
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
|
||||
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
|
||||
|
||||
registerWorkflowRoutes(apiGroup)
|
||||
registerMonitorRoutes(apiGroup)
|
||||
registerChannelRoutes(apiGroup)
|
||||
|
||||
instance := apiGroup.Group("/instance")
|
||||
instance.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
instance.GET("/users", listInstanceUsers)
|
||||
instance.POST("/users", createInstanceUser)
|
||||
instance.PUT("/users/:id/role", updateInstanceUserRole)
|
||||
instance.DELETE("/users/:id", deleteInstanceUser)
|
||||
}
|
||||
|
||||
providers := apiGroup.Group("/auth/providers")
|
||||
providers.Use(auth.RequireRole("owner", "admin"), RequireFeature("oidc"))
|
||||
{
|
||||
providers.GET("", listAuthProviders)
|
||||
providers.POST("", createAuthProvider)
|
||||
providers.PUT("/:id", updateAuthProvider)
|
||||
providers.DELETE("/:id", deleteAuthProvider)
|
||||
providers.POST("/:id/test", testAuthProvider)
|
||||
providers.POST("/:id/ack-notice", ackAuthProviderNotice)
|
||||
}
|
||||
apiGroup.GET("/auth/presets", auth.RequireRole("owner", "admin"), listAuthPresets)
|
||||
|
||||
apiGroup.GET("/vulnerabilities", listVulnerabilities)
|
||||
apiGroup.GET("/vulnerabilities/summary", vulnerabilitySummary)
|
||||
apiGroup.POST("/vulnerabilities/rescan", auth.RequireRole("owner", "admin"), rescanVulnerabilities)
|
||||
apiGroup.POST("/vulnerabilities/:id/accept", auth.RequireRole("owner", "admin"), acceptFinding)
|
||||
apiGroup.DELETE("/vulnerabilities/:id/accept", auth.RequireRole("owner", "admin"), unacceptFinding)
|
||||
apiGroup.GET("/servers/:id/vulnerabilities", listServerVulnerabilities)
|
||||
apiGroup.GET("/servers/:id/packages", getServerPackages)
|
||||
apiGroup.GET("/packages/search", searchPackages)
|
||||
apiGroup.GET("/vuln-rules", listVulnRules)
|
||||
apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
|
||||
apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
|
||||
apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
|
||||
|
||||
// Control actions and log reads are owner|admin: container output is
|
||||
// arbitrary and cannot be masked, so a member who can see the fleet
|
||||
// still cannot read its logs.
|
||||
apiGroup.GET("/workloads", listWorkloads)
|
||||
apiGroup.GET("/servers/:id/workloads", getServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
|
||||
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
|
||||
|
||||
registerStatusPageRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
// listServers godoc
|
||||
//
|
||||
// @Summary List servers
|
||||
// @Description Returns every server in the instance, optionally filtered by tag (repeatable, key:value).
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param tag query []string false "Filter by tag as key:value, repeatable"
|
||||
// @Success 200 {array} models.Server
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers [get]
|
||||
func listServers(c *gin.Context) {
|
||||
servers, err := services.ListServers()
|
||||
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -55,77 +198,215 @@ func listServers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, servers)
|
||||
}
|
||||
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer()
|
||||
// listKnownTags godoc
|
||||
//
|
||||
// @Summary List known tags
|
||||
// @Description Returns every tag key currently used by any server, with the values seen for each.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string][]string
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/tags [get]
|
||||
func listKnownTags(c *gin.Context) {
|
||||
tags, err := services.KnownTags(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"server": s,
|
||||
"token": token,
|
||||
"server_id": s.ServerID,
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// putServerTags godoc
|
||||
//
|
||||
// @Summary Replace a server's tags
|
||||
// @Description Replaces the whole tag map for a server. Last write wins.
|
||||
// @Tags servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param body body object{tags=map[string]string} true "New tag map"
|
||||
// @Success 200 {object} TagsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/tags [put]
|
||||
func putServerTags(c *gin.Context) {
|
||||
var body struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
serverID := c.Param("id")
|
||||
|
||||
before, err := services.GetServer(instanceID, serverID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.SetServerTags(instanceID, serverID, body.Tags); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidTag) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "",
|
||||
fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
|
||||
c.JSON(http.StatusOK, TagsResponse{Tags: body.Tags})
|
||||
}
|
||||
|
||||
// createServer godoc
|
||||
//
|
||||
// @Summary Add a server
|
||||
// @Description Creates a server record and a single-use pre-registration token (TTL 1 hour).
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 201 {object} CreateServerResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers [post]
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, CreateServerResponse{
|
||||
Server: s,
|
||||
Token: token,
|
||||
ServerID: s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
// newServer godoc
|
||||
//
|
||||
// @Summary Add a server (install page)
|
||||
// @Description Identical to POST /servers; also reachable by GET for the install page. Mints a new pre-registration token.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} NewServerResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/new [get]
|
||||
// @Router /servers/new [post]
|
||||
func newServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer()
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
|
||||
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
host := os.Getenv("PUBLIC_HOST")
|
||||
if host == "" {
|
||||
host = "https://vantage.example.com"
|
||||
}
|
||||
host := publicHostFromRequest(c)
|
||||
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL "%s/install?server_id=%s&token=%s" | bash`,
|
||||
host, s.ServerID, token,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"server_id": s.ServerID,
|
||||
"pre_reg_token": token,
|
||||
"install_command": installCmd,
|
||||
installCmdPS := fmt.Sprintf(
|
||||
`irm "%s/install.ps1?server_id=%s&token=%s" | iex`,
|
||||
host, s.ServerID, token,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, NewServerResponse{
|
||||
ServerID: s.ServerID,
|
||||
PreRegToken: token,
|
||||
InstallCommand: installCmd,
|
||||
InstallCommandPS: installCmdPS,
|
||||
})
|
||||
}
|
||||
|
||||
// getServer godoc
|
||||
//
|
||||
// @Summary Get a server
|
||||
// @Description Returns a server together with its resolved key assignments.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} ServerDetailResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id} [get]
|
||||
func getServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(id)
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
|
||||
|
||||
// Build response matching ServerWithKeys shape expected by frontend
|
||||
type serverResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
}
|
||||
c.JSON(http.StatusOK, serverResponse{
|
||||
c.JSON(http.StatusOK, ServerDetailResponse{
|
||||
Server: s,
|
||||
Keys: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteServer godoc
|
||||
//
|
||||
// @Summary Delete a server
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id} [delete]
|
||||
func deleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := services.DeleteServer(id); err != nil {
|
||||
s, _ := services.GetServer(auth.InstanceID(c), id)
|
||||
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
hostname := id
|
||||
if s != nil {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// generateKey godoc
|
||||
//
|
||||
// @Summary Generate a key on a server
|
||||
// @Description Dispatches an agent command that generates a keypair on the target server and reports it back.
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param body body object{label=string,key_type=string,key_size=int,passphrase=string,comment=string} false "Key generation parameters"
|
||||
// @Success 202 {object} GenerateKeyResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/generate-key [post]
|
||||
func generateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -141,7 +422,7 @@ func generateKey(c *gin.Context) {
|
||||
body.Label = "generated"
|
||||
}
|
||||
|
||||
s, err := services.GetServer(id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -159,15 +440,26 @@ func generateKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "key generation command sent to agent",
|
||||
"command_id": cmdID,
|
||||
"server_id": s.ServerID,
|
||||
services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
|
||||
c.JSON(http.StatusAccepted, GenerateKeyResponse{
|
||||
Message: "key generation command sent to agent",
|
||||
CommandID: cmdID,
|
||||
ServerID: s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
// listKeys godoc
|
||||
//
|
||||
// @Summary List keys
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Key
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys [get]
|
||||
func listKeys(c *gin.Context) {
|
||||
keys, err := services.ListKeys()
|
||||
keys, err := services.ListKeys(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -175,64 +467,130 @@ func listKeys(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// createKey godoc
|
||||
//
|
||||
// @Summary Upload a key
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{label=string,public_key=string,private_key=string,passphrase=string} true "Key material"
|
||||
// @Success 201 {object} models.Key
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys [post]
|
||||
func createKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
PublicKey string `json:"public_key" binding:"required"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
|
||||
key, err := services.CreateKey(auth.InstanceID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
|
||||
c.JSON(http.StatusCreated, key)
|
||||
}
|
||||
|
||||
// getPrivateKey godoc
|
||||
//
|
||||
// @Summary Get a key's private material
|
||||
// @Description Returns the decrypted private key. Reading is a keys:read action even though the material is sensitive.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} PrivateKeyResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/private-key [get]
|
||||
func getPrivateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
plaintext, err := services.GetPrivateKey(id)
|
||||
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
|
||||
c.JSON(http.StatusOK, PrivateKeyResponse{PrivateKey: plaintext})
|
||||
}
|
||||
|
||||
// getKey godoc
|
||||
//
|
||||
// @Summary Get a key
|
||||
// @Description Returns a key together with the servers it is assigned to.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} KeyDetailResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id} [get]
|
||||
func getKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key, err := services.GetKey(id)
|
||||
key, err := services.GetKey(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithServers(id)
|
||||
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
type keyResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
c.JSON(http.StatusOK, keyResponse{
|
||||
c.JSON(http.StatusOK, KeyDetailResponse{
|
||||
Key: key,
|
||||
Assignments: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteKey godoc
|
||||
//
|
||||
// @Summary Delete a key
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id} [delete]
|
||||
func deleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := services.DeleteKey(id); err != nil {
|
||||
k, _ := services.GetKey(auth.InstanceID(c), id)
|
||||
if err := services.DeleteKey(auth.InstanceID(c), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
label := id
|
||||
if k != nil {
|
||||
label = k.Label
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// assignKey godoc
|
||||
//
|
||||
// @Summary Assign a key to a server
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Param body body object{server_id=string} true "Target server"
|
||||
// @Success 201 {object} models.Assignment
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/assign [post]
|
||||
func assignKey(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
var body struct {
|
||||
@@ -243,37 +601,76 @@ func assignKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := services.AssignKey(keyID, body.ServerID)
|
||||
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
|
||||
c.JSON(http.StatusCreated, a)
|
||||
}
|
||||
|
||||
// revokeAssignment godoc
|
||||
//
|
||||
// @Summary Revoke a key assignment
|
||||
// @Description Soft revocation: sets revoked_at rather than deleting, preserving audit history.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {object} RevokedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/assign/{serverId} [delete]
|
||||
func revokeAssignment(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
serverID := c.Param("serverId")
|
||||
|
||||
if err := services.RevokeAssignment(keyID, serverID); err != nil {
|
||||
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
|
||||
}
|
||||
|
||||
// getLatestAgentVersion godoc
|
||||
//
|
||||
// @Summary Get the latest agent version
|
||||
// @Description Reads the latest agent/v* tag from the Gitea release API.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} AgentVersionResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /agent/latest-version [get]
|
||||
func getLatestAgentVersion(c *gin.Context) {
|
||||
version, err := services.GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"version": version})
|
||||
c.JSON(http.StatusOK, AgentVersionResponse{Version: version})
|
||||
}
|
||||
|
||||
// updateAgent godoc
|
||||
//
|
||||
// @Summary Update a server's agent
|
||||
// @Description Dispatches UpdateAgentCmd to the agent, telling it to download and replace itself.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} UpdateAgentResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/update-agent [post]
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -284,18 +681,52 @@ func updateAgent(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "update command sent to agent",
|
||||
"version": version,
|
||||
services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
|
||||
c.JSON(http.StatusAccepted, UpdateAgentResponse{
|
||||
Message: "update command sent to agent",
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
// applyUpdates godoc
|
||||
//
|
||||
// @Summary Apply pending OS updates on a server
|
||||
// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} MessageResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/apply-updates [post]
|
||||
func applyUpdates(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
|
||||
}
|
||||
|
||||
// handleUpdateScript serves a dynamically generated shell script that
|
||||
// downloads and installs the latest agent. Deliberately not in the generated
|
||||
// OpenAPI document: it is registered on the bare engine, not under the /api
|
||||
// group the document's BasePath assumes, so a @Router annotation here would
|
||||
// publish /api/update — a path that 404s — rather than the real top-level
|
||||
// /update. It serves a shell script, not JSON, so there is nothing lost by
|
||||
// leaving it out of a JSON API reference.
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := "gitea.hostxtra.co.uk"
|
||||
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
@@ -309,7 +740,7 @@ case "$ARCH" in
|
||||
esac
|
||||
|
||||
# Get latest agent release tag
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/vantage/vantage-agent/releases?limit=10" \
|
||||
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
@@ -319,8 +750,8 @@ fi
|
||||
|
||||
VERSION="${LATEST#agent/}"
|
||||
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||
BINARY_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||
|
||||
echo "Updating vantage-agent to ${VERSION} (${ARCH})..."
|
||||
|
||||
@@ -346,6 +777,118 @@ echo "vantage-agent updated to ${VERSION} and restarted."
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
// listAuditEvents godoc
|
||||
//
|
||||
// @Summary List audit events
|
||||
// @Description Every mutating API path writes an audit event. Paginated with a total, since a short page is not proof of the end of the log.
|
||||
// @Tags audit
|
||||
// @Produce json
|
||||
// @Param q query string false "Free-text search"
|
||||
// @Param category query string false "Filter by category"
|
||||
// @Param limit query int false "Max events to return"
|
||||
// @Param skip query int false "Events to skip"
|
||||
// @Success 200 {object} AuditEventsResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /audit [get]
|
||||
func listAuditEvents(c *gin.Context) {
|
||||
f := services.AuditFilter{
|
||||
Search: c.Query("q"),
|
||||
Category: c.Query("category"),
|
||||
}
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
if s := c.Query("skip"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n >= 0 {
|
||||
f.Skip = n
|
||||
}
|
||||
}
|
||||
|
||||
events, total, err := services.ListAuditEvents(auth.InstanceID(c), f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// An object rather than a bare array: a page is meaningless without the
|
||||
// total it came from, and a short page is not proof of the end of the log.
|
||||
c.JSON(http.StatusOK, AuditEventsResponse{Events: events, Total: total})
|
||||
}
|
||||
|
||||
// getSettings godoc
|
||||
//
|
||||
// @Summary Get instance settings
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Settings
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings [get]
|
||||
func getSettings(c *gin.Context) {
|
||||
s, err := services.GetSettings(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// saveSettings godoc
|
||||
//
|
||||
// @Summary Save instance settings
|
||||
// @Description Owner and admin only. Refuses a change that would leave neither local login nor an enabled auth provider.
|
||||
// @Tags settings
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{alerts=models.AlertSettings,workflow_log_retention_days=int,local_login_enabled=bool,api_token_max_days=int} true "Settings to save"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings [put]
|
||||
func saveSettings(c *gin.Context) {
|
||||
var body struct {
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
|
||||
LocalLoginEnabled *bool `json:"local_login_enabled"`
|
||||
APITokenMaxDays *int `json:"api_token_max_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.APITokenMaxDays != nil && *body.APITokenMaxDays < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
|
||||
if errors.Is(err, services.ErrLockout) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
if body.APITokenMaxDays != nil {
|
||||
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
|
||||
}
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// handleInstallScript serves a dynamically generated shell script that
|
||||
// downloads, verifies and installs the agent, seeded with a pre-registration
|
||||
// token. Deliberately not in the generated OpenAPI document, for the same
|
||||
// reason as handleUpdateScript: it is registered on the bare engine, outside
|
||||
// the /api group the document's BasePath assumes, so a @Router annotation
|
||||
// would publish a /api/install path that 404s.
|
||||
func handleInstallScript(c *gin.Context) {
|
||||
serverID := c.Query("server_id")
|
||||
token := c.Query("token")
|
||||
@@ -354,14 +897,7 @@ func handleInstallScript(c *gin.Context) {
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
publicHost := os.Getenv("PUBLIC_HOST")
|
||||
if publicHost == "" {
|
||||
publicHost = "vantage.example.com"
|
||||
}
|
||||
grpcHost := os.Getenv("GRPC_HOST")
|
||||
if grpcHost == "" {
|
||||
grpcHost = publicHost
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -369,9 +905,6 @@ set -euo pipefail
|
||||
SERVER_ID="%s"
|
||||
TOKEN="%s"
|
||||
GITEA_HOST="%s"
|
||||
KM_HOST="%s"
|
||||
KM_HOST="${KM_HOST#https://}"
|
||||
KM_HOST="${KM_HOST#http://}"
|
||||
GRPC_HOST="%s"
|
||||
GRPC_HOST="${GRPC_HOST#https://}"
|
||||
GRPC_HOST="${GRPC_HOST#http://}"
|
||||
@@ -384,7 +917,7 @@ case "$ARCH" in
|
||||
esac
|
||||
|
||||
# Get latest agent release tag
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/vantage/vantage-agent/releases?limit=10" \
|
||||
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
@@ -394,8 +927,8 @@ fi
|
||||
|
||||
VERSION="${LATEST#agent/}"
|
||||
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||
BINARY_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||
|
||||
echo "Installing vantage-agent ${VERSION} (${ARCH})..."
|
||||
|
||||
@@ -444,7 +977,7 @@ systemctl daemon-reload
|
||||
systemctl enable --now vantage-agent
|
||||
|
||||
echo "vantage-agent installed and started."
|
||||
`, serverID, token, giteaHost, publicHost, grpcHost)
|
||||
`, serverID, token, giteaHost, grpcHost)
|
||||
|
||||
c.Header("Content-Type", "text/x-shellscript")
|
||||
c.String(http.StatusOK, script)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The two probes answer different questions on purpose.
|
||||
//
|
||||
// /healthz is liveness: the process is up and serving. It touches nothing
|
||||
// external, because a Mongo outage must not make Kubernetes restart every
|
||||
// server pod — a restart loop cannot fix someone else's database, and it
|
||||
// destroys every open command stream and console session on the way.
|
||||
//
|
||||
// /readyz is readiness: this pod can serve a request end to end, which needs
|
||||
// both Mongo and Redis. A failing readiness probe pulls the pod out of the
|
||||
// Service and leaves it running, which is the behaviour that matters during a
|
||||
// dependency blip.
|
||||
//
|
||||
// Both sit outside /api, so neither the session middleware nor the licence
|
||||
// gate applies. Neither reveals anything beyond up or down.
|
||||
|
||||
const probeTimeout = 2 * time.Second
|
||||
|
||||
func handleHealthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func handleReadyz(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
checks := gin.H{"mongo": "ok", "redis": "ok"}
|
||||
ready := true
|
||||
|
||||
if db.Client == nil {
|
||||
checks["mongo"] = "not initialised"
|
||||
ready = false
|
||||
} else if err := db.Client.Ping(ctx, nil); err != nil {
|
||||
checks["mongo"] = "unreachable"
|
||||
ready = false
|
||||
}
|
||||
|
||||
if err := auth.PingRedis(ctx); err != nil {
|
||||
checks["redis"] = "unreachable"
|
||||
ready = false
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
state := "ok"
|
||||
if !ready {
|
||||
status = http.StatusServiceUnavailable
|
||||
state = "unready"
|
||||
}
|
||||
c.JSON(status, gin.H{"status": state, "checks": checks})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func handleInstallScriptWindows(c *gin.Context) {
|
||||
serverID := c.Query("server_id")
|
||||
token := c.Query("token")
|
||||
|
||||
giteaHost := "gitea.hostxtra.co.uk"
|
||||
|
||||
grpcHost := os.Getenv("GRPC_HOST")
|
||||
|
||||
script := fmt.Sprintf(
|
||||
"#Requires -RunAsAdministrator\n"+
|
||||
"$ErrorActionPreference = \"Stop\"\n"+
|
||||
"\n"+
|
||||
"$ServerId = \"%s\"\n"+
|
||||
"$Token = \"%s\"\n"+
|
||||
"$GiteaHost = \"%s\"\n"+
|
||||
"$ServerUrl = \"%s\" -replace '^https?://',''\n"+
|
||||
"\n"+
|
||||
"$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/vantage/vantage-agent/releases?limit=10\"\n"+
|
||||
"$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+
|
||||
"if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+
|
||||
"$enc = $tag -replace '/','%%2F'\n"+
|
||||
"$base = \"https://$GiteaHost/vantage/vantage-agent/releases/download/$enc\"\n"+
|
||||
"\n"+
|
||||
"$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+
|
||||
"Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+
|
||||
"Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+
|
||||
"\n"+
|
||||
"$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+
|
||||
"$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+
|
||||
"if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+
|
||||
"\n"+
|
||||
"Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn SERVERID=$ServerId TOKEN=$Token SERVERURL=$ServerUrl\"\n"+
|
||||
"Write-Host \"Vantage agent installed.\"\n",
|
||||
serverID, token, giteaHost, grpcHost)
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
func handleUpdateScriptWindows(c *gin.Context) {
|
||||
giteaHost := "gitea.hostxtra.co.uk"
|
||||
|
||||
script := fmt.Sprintf(
|
||||
"#Requires -RunAsAdministrator\n"+
|
||||
"$ErrorActionPreference = \"Stop\"\n"+
|
||||
"\n"+
|
||||
"$GiteaHost = \"%s\"\n"+
|
||||
"\n"+
|
||||
"$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/vantage/vantage-agent/releases?limit=10\"\n"+
|
||||
"$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+
|
||||
"if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+
|
||||
"$enc = $tag -replace '/','%%2F'\n"+
|
||||
"$base = \"https://$GiteaHost/vantage/vantage-agent/releases/download/$enc\"\n"+
|
||||
"\n"+
|
||||
"$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+
|
||||
"Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+
|
||||
"Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+
|
||||
"\n"+
|
||||
"$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+
|
||||
"$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+
|
||||
"if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+
|
||||
"\n"+
|
||||
"Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn /norestart\"\n"+
|
||||
"Write-Host \"Vantage agent updated to $tag.\"\n",
|
||||
giteaHost)
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// listInstanceUsers godoc
|
||||
//
|
||||
// @Summary List instance members
|
||||
// @Tags instance-users
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.User
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [get]
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func actorMayGrantOwner(c *gin.Context) bool {
|
||||
return auth.Role(c) == models.RoleOwner
|
||||
}
|
||||
|
||||
// createInstanceUser godoc
|
||||
//
|
||||
// @Summary Create an instance member
|
||||
// @Description Only an owner can create another owner.
|
||||
// @Tags instance-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{email=string,password=string,role=string} true "New member"
|
||||
// @Success 201 {object} models.User
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [post]
|
||||
func createInstanceUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = models.RoleMember
|
||||
}
|
||||
if !models.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
if body.Role == models.RoleOwner && !actorMayGrantOwner(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"})
|
||||
return
|
||||
}
|
||||
u, err := services.CreateUser(auth.InstanceID(c), body.Email, body.Password, body.Role, "local")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
|
||||
// updateInstanceUserRole godoc
|
||||
//
|
||||
// @Summary Change an instance member's role
|
||||
// @Description A caller cannot change their own role. Only an owner can change owner roles.
|
||||
// @Tags instance-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Param body body object{role=string} true "New role"
|
||||
// @Success 200 {object} OKResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users/{id}/role [put]
|
||||
func updateInstanceUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role required"})
|
||||
return
|
||||
}
|
||||
if !models.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
|
||||
return
|
||||
}
|
||||
target, err := services.GetUserInInstance(instanceID, targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if (body.Role == models.RoleOwner || target.Role == models.RoleOwner) && !actorMayGrantOwner(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.UpdateUserRole(instanceID, targetID, body.Role); err != nil {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||||
}
|
||||
|
||||
// deleteInstanceUser godoc
|
||||
//
|
||||
// @Summary Remove an instance member
|
||||
// @Description A caller cannot remove their own account. Only an owner can remove another owner.
|
||||
// @Tags instance-users
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users/{id} [delete]
|
||||
func deleteInstanceUser(c *gin.Context) {
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
|
||||
return
|
||||
}
|
||||
target, err := services.GetUserInInstance(instanceID, targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if target.Role == models.RoleOwner && !actorMayGrantOwner(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DeleteUser(instanceID, targetID); err != nil {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
func orgUserErrStatus(err error) int {
|
||||
// 409 rather than 403: the caller has the right to manage members, and the
|
||||
// request is refused because of the resource's state, not their permissions.
|
||||
if errors.Is(err, services.ErrLastOwner) || errors.Is(err, services.ErrHQManaged) {
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// licenceExemptPaths are routes that must work while a licence is expired or
|
||||
// missing, because they are how a customer recovers or stays safe.
|
||||
//
|
||||
// /api/license pasting a valid licence is the way out of degraded mode
|
||||
// apply-updates security patching is never paywalled
|
||||
//
|
||||
// All DELETE requests are exempt separately (see RequireActiveLicense): a
|
||||
// customer downgraded below their current usage must be able to delete their
|
||||
// way back under the cap.
|
||||
var licenceExemptPaths = map[string]bool{
|
||||
"/api/license": true,
|
||||
}
|
||||
|
||||
// mutatingGETs are routes that change state despite their method. GET is
|
||||
// otherwise always allowed through, so these have to be named explicitly:
|
||||
// GET /api/servers/new mints a pre-registration token, which is a creation.
|
||||
var mutatingGETs = map[string]bool{
|
||||
"/api/servers/new": true,
|
||||
}
|
||||
|
||||
func licenceExempt(c *gin.Context) bool {
|
||||
if c.Request.Method == http.MethodDelete {
|
||||
return true
|
||||
}
|
||||
if licenceExemptPaths[c.FullPath()] {
|
||||
return true
|
||||
}
|
||||
if c.FullPath() == "/api/servers/:id/apply-updates" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequireActiveLicense blocks mutating requests when the licence is not valid.
|
||||
//
|
||||
// Mounted on the /api group, so a route added tomorrow is gated because of where
|
||||
// it lives rather than because someone remembered. GET and HEAD always pass —
|
||||
// reading is never blocked.
|
||||
func RequireActiveLicense() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) &&
|
||||
!mutatingGETs[c.FullPath()] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if licenceExempt(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
st := services.GetLicenseState(auth.InstanceID(c))
|
||||
if st.Active() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "license_required",
|
||||
"state": st.Status,
|
||||
"reason": st.Reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireFeature blocks a route when the licence does not grant a feature.
|
||||
func RequireFeature(name string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
st := services.GetLicenseState(auth.InstanceID(c))
|
||||
if st.Feature(name) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "feature_unavailable",
|
||||
"feature": name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type licenceResponse struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
State license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
SupportLevel string `json:"support_level,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
DaysRemaining *int `json:"days_remaining,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
Usage licenceUsageResponse `json:"usage"`
|
||||
Source string `json:"source"`
|
||||
// Deployment is what tells the UI whether this instance owns its licence.
|
||||
// On cloud the licence is injected by admin and there is nothing for a
|
||||
// customer to paste, so the UI sends them to the portal instead.
|
||||
Deployment string `json:"deployment"`
|
||||
}
|
||||
|
||||
type licenceUsageResponse struct {
|
||||
Servers int `json:"servers"`
|
||||
Monitors int `json:"monitors"`
|
||||
SecretGroups int `json:"secret_groups"`
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
|
||||
// getLicence godoc
|
||||
//
|
||||
// @Summary Get this instance's licence state
|
||||
// @Tags licence
|
||||
// @Produce json
|
||||
// @Success 200 {object} licenceResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /license [get]
|
||||
func getLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
st := services.GetLicenseState(instanceID)
|
||||
servers, monitors, groups, channels := services.LicenseUsage(instanceID)
|
||||
|
||||
resp := licenceResponse{
|
||||
InstanceID: instanceID,
|
||||
State: st.Status,
|
||||
Reason: st.Reason,
|
||||
Tier: st.Tier,
|
||||
SupportLevel: st.SupportLevel,
|
||||
ExpiresAt: st.ExpiresAt,
|
||||
Limits: st.Limits,
|
||||
Features: st.Features,
|
||||
Usage: licenceUsageResponse{Servers: servers, Monitors: monitors, SecretGroups: groups, Channels: channels},
|
||||
Source: st.Source,
|
||||
Deployment: services.DeploymentMode(),
|
||||
}
|
||||
if st.ExpiresAt != nil {
|
||||
d := int(time.Until(*st.ExpiresAt).Hours() / 24)
|
||||
resp.DaysRemaining = &d
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
var (
|
||||
licencePostMu sync.Mutex
|
||||
licencePostCounts = map[string][]time.Time{}
|
||||
)
|
||||
|
||||
const licencePostLimit = 10
|
||||
|
||||
// licencePostAllowed permits 10 attempts per instance per hour.
|
||||
func licencePostAllowed(instanceID string) bool {
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
|
||||
licencePostMu.Lock()
|
||||
defer licencePostMu.Unlock()
|
||||
|
||||
kept := licencePostCounts[instanceID][:0]
|
||||
for _, t := range licencePostCounts[instanceID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= licencePostLimit {
|
||||
licencePostCounts[instanceID] = kept
|
||||
return false
|
||||
}
|
||||
licencePostCounts[instanceID] = append(kept, time.Now())
|
||||
return true
|
||||
}
|
||||
|
||||
// postLicence godoc
|
||||
//
|
||||
// @Summary Set this instance's licence
|
||||
// @Description Self-hosted only; a cloud instance's licence is injected by admin and this endpoint answers 409 cloud_managed. Exempt from the licence gate, since pasting a valid licence is the way out of degraded mode. Rate limited to 10 attempts per instance per hour.
|
||||
// @Tags licence
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{blob=string} true "Licence key blob"
|
||||
// @Success 200 {object} LicencePostResponse
|
||||
// @Failure 400 {object} LicenceErrorResponse
|
||||
// @Failure 409 {object} LicenceErrorResponse
|
||||
// @Failure 429 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /license [post]
|
||||
func postLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
|
||||
// A cloud instance's licence is written by admin straight into the database
|
||||
// (admin/internal/inject), never through this endpoint, so refusing here
|
||||
// cannot break injection. Hiding the form in web/ is a courtesy; this is the
|
||||
// boundary, the same split as an hq-managed user's role.
|
||||
if services.DeploymentMode() == license.DeploymentCloud {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "This instance's licence is managed in Vantage HQ and cannot be set here.",
|
||||
"reason": "cloud_managed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !licencePostAllowed(instanceID) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many licence attempts. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Blob string `json:"blob"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
|
||||
return
|
||||
}
|
||||
|
||||
st, err := services.StoreLicense(instanceID, body.Blob)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": licenceRejectionMessage(err.Error(), instanceID),
|
||||
"reason": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
|
||||
"licence accepted (tier "+st.Tier+")")
|
||||
c.JSON(http.StatusOK, LicencePostResponse{State: st.Status, Tier: st.Tier, ExpiresAt: st.ExpiresAt})
|
||||
}
|
||||
|
||||
// licenceRejectionMessage turns a machine reason into something a person can act
|
||||
// on. The instance ID is included in the mismatch case because that is the one
|
||||
// piece of information the customer needs and cannot guess.
|
||||
func licenceRejectionMessage(reason, instanceID string) string {
|
||||
switch reason {
|
||||
case license.ReasonBadSignature:
|
||||
return "This licence key is not valid. Check it was copied in full."
|
||||
case license.ReasonDeploymentMismatch:
|
||||
return "This licence was issued for a different instance type."
|
||||
case license.ReasonInstanceMismatch:
|
||||
return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
|
||||
case license.ReasonNoLicense:
|
||||
return "No licence key was provided."
|
||||
default:
|
||||
return "This licence could not be accepted."
|
||||
}
|
||||
}
|
||||
|
||||
// limitStatus maps a LimitError to a 403 body. Handlers that create countable
|
||||
// resources call this so the UI gets a machine-readable limit name.
|
||||
func limitStatus(c *gin.Context, err error) bool {
|
||||
var le *services.LimitError
|
||||
if !errors.As(err, &le) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusForbidden, LimitExceededResponse{
|
||||
Error: "limit_exceeded",
|
||||
Limit: le.Limit,
|
||||
Current: le.Current,
|
||||
Max: le.Max,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors", listMonitors)
|
||||
g.POST("/monitors", createMonitor)
|
||||
g.GET("/monitors/:id", getMonitor)
|
||||
g.PUT("/monitors/:id", updateMonitor)
|
||||
g.DELETE("/monitors/:id", deleteMonitor)
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
g.GET("/monitors/:id/samples", getMonitorSamples)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
//
|
||||
// @Summary List monitors
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Monitor
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors [get]
|
||||
func listMonitors(c *gin.Context) {
|
||||
monitors, err := services.ListMonitors(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, monitors)
|
||||
}
|
||||
|
||||
// createMonitor godoc
|
||||
//
|
||||
// @Summary Create a monitor
|
||||
// @Tags monitors
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.Monitor true "Monitor to create"
|
||||
// @Success 201 {object} models.Monitor
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors [post]
|
||||
func createMonitor(c *gin.Context) {
|
||||
var m models.Monitor
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m.Name == "" || m.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
if err := services.CheckMonitorLimit(auth.InstanceID(c)); err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// getMonitor godoc
|
||||
//
|
||||
// @Summary Get a monitor
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {object} models.Monitor
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [get]
|
||||
func getMonitor(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// updateMonitor godoc
|
||||
//
|
||||
// @Summary Update a monitor
|
||||
// @Tags monitors
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [put]
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Group *string `json:"group"`
|
||||
Type *string `json:"type"`
|
||||
Target *models.MonitorTarget `json:"target"`
|
||||
IntervalSec *int `json:"interval_sec"`
|
||||
Runner *string `json:"runner"`
|
||||
Retries *int `json:"retries"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ChannelIDs *[]string `json:"channel_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
upd := bson.M{}
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Group != nil {
|
||||
upd["group"] = *body.Group
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
if body.Target != nil {
|
||||
upd["target"] = *body.Target
|
||||
}
|
||||
if body.IntervalSec != nil {
|
||||
upd["interval_sec"] = *body.IntervalSec
|
||||
}
|
||||
if body.Runner != nil {
|
||||
upd["runner"] = *body.Runner
|
||||
}
|
||||
if body.Retries != nil {
|
||||
upd["retries"] = *body.Retries
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
upd["enabled"] = *body.Enabled
|
||||
}
|
||||
if body.ChannelIDs != nil {
|
||||
upd["channel_ids"] = *body.ChannelIDs
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteMonitor godoc
|
||||
//
|
||||
// @Summary Delete a monitor
|
||||
// @Tags monitors
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 204
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [delete]
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getMonitorIncidents godoc
|
||||
//
|
||||
// @Summary List a monitor's incidents
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.Incident
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/incidents [get]
|
||||
func getMonitorIncidents(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
incidents, err := services.ListIncidents(auth.InstanceID(c), c.Param("id"), 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// getMonitorSamples godoc
|
||||
//
|
||||
// @Summary Get a monitor's individual check results
|
||||
// @Description Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param minutes query int false "Window in minutes (default 60, max 2880)"
|
||||
// @Success 200 {array} models.MonitorSample
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/samples [get]
|
||||
func getMonitorSamples(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
// Clamped rather than rejected: the window is a view setting, and the only
|
||||
// honest answer past the TTL is the shorter window anyway.
|
||||
minutes := 60
|
||||
if raw := c.Query("minutes"); raw != "" {
|
||||
if n, convErr := strconv.Atoi(raw); convErr == nil && n > 0 {
|
||||
minutes = n
|
||||
}
|
||||
}
|
||||
if max := int(services.MonitorSampleTTL.Minutes()); minutes > max {
|
||||
minutes = max
|
||||
}
|
||||
samples, err := services.MonitorSamples(auth.InstanceID(c), c.Param("id"), time.Now().Add(-time.Duration(minutes)*time.Minute))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, samples)
|
||||
}
|
||||
|
||||
// getMonitorUptime godoc
|
||||
//
|
||||
// @Summary Get a monitor's uptime rollups
|
||||
// @Description Hourly rollups for the last 30 days.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.Rollup
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/uptime [get]
|
||||
func getMonitorUptime(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
since := time.Now().Add(-30 * 24 * time.Hour)
|
||||
rollups, err := services.UptimeRollups(auth.InstanceID(c), c.Param("id"), since)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rollups)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api/docs"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// scalarPage renders the reference against this instance's own spec, so "Try
|
||||
// it" acts on the reader's API with the reader's session.
|
||||
const scalarPage = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Vantage API</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/api/docs/scalar.js"></script>
|
||||
<script>
|
||||
Scalar.createApiReference('#app', {
|
||||
url: '/api/openapi.json',
|
||||
theme: 'deepSpace',
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// getOpenAPI godoc
|
||||
//
|
||||
// @Summary Get the OpenAPI document
|
||||
// @Description Generated from swaggo annotations at build time and committed; served verbatim.
|
||||
// @Tags docs
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /openapi.json [get]
|
||||
func getOpenAPI(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", docs.OpenAPI)
|
||||
}
|
||||
|
||||
// getScalarJS godoc
|
||||
//
|
||||
// @Summary Get the vendored Scalar bundle
|
||||
// @Description Served locally rather than from a CDN so the reference page works on an air-gapped install.
|
||||
// @Tags docs
|
||||
// @Produce application/javascript
|
||||
// @Success 200 {string} string "javascript bundle"
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /docs/scalar.js [get]
|
||||
func getScalarJS(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/javascript; charset=utf-8", docs.ScalarJS)
|
||||
}
|
||||
|
||||
// getAPIDocs godoc
|
||||
//
|
||||
// @Summary API reference page
|
||||
// @Description Renders the Scalar reference against this instance's own OpenAPI document.
|
||||
// @Tags docs
|
||||
// @Produce html
|
||||
// @Success 200 {string} string "HTML page"
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /docs [get]
|
||||
func getAPIDocs(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarPage))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func publicHostFromRequest(c *gin.Context) string {
|
||||
host := c.Request.Host
|
||||
if h := firstForwarded(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
host = h
|
||||
}
|
||||
if host == "" {
|
||||
return "https://vantage.example.com"
|
||||
}
|
||||
return schemeFor(c, host) + "://" + host
|
||||
}
|
||||
|
||||
func schemeFor(c *gin.Context, host string) string {
|
||||
if p := firstForwarded(c.GetHeader("X-Forwarded-Proto")); p != "" {
|
||||
return p
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if isLoopback(host) {
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
||||
func firstForwarded(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(v, ",")[0])
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
h = host
|
||||
}
|
||||
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// publicStatusRateLimit is per client address per minute. Generous enough that
|
||||
// a busy page during an outage is unaffected, small enough that scanning for
|
||||
// page ids is not free.
|
||||
const publicStatusRateLimit = 120
|
||||
|
||||
// RateLimitPublicStatus counts requests per client address in a one-minute
|
||||
// fixed window, exactly as RateLimitTokens does — including the part that
|
||||
// matters most: when Redis is unavailable it allows rather than denies. A
|
||||
// status page must survive the outage it exists to report.
|
||||
func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
window := time.Now().UTC().Unix() / 60
|
||||
key := "vantage:statusrl:" + c.ClientIP() + ":" + strconv.FormatInt(window, 10)
|
||||
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
|
||||
}
|
||||
if count > publicStatusRateLimit {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "too many requests",
|
||||
"code": "rate_limited",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// getPublicStatusPage is the only unauthenticated read of monitor data in the
|
||||
// product.
|
||||
//
|
||||
// It is mounted on the gin root rather than under /api on purpose: /api
|
||||
// carries auth.Middleware, RequireScopes, RateLimitTokens and
|
||||
// RequireActiveLicense by virtue of where it is mounted, and a public route
|
||||
// there would need four exemptions, each one a hole a later change can widen.
|
||||
//
|
||||
// Unknown host, unknown page and unpublished page all answer the same 404.
|
||||
//
|
||||
// It carries no @Router annotation deliberately. openapi.json declares a
|
||||
// single server of "/api", so a @Router of /public/status/{pageId} would be
|
||||
// published as /api/public/status/{pageId} — a path that does not exist, and
|
||||
// which would sit behind auth.Middleware if it did. The real address is:
|
||||
//
|
||||
// GET {scheme}://{instance-host}/public/status/{pageId}
|
||||
//
|
||||
// on the gin root, unauthenticated, rate limited per client address.
|
||||
//
|
||||
// @Summary Public status page
|
||||
// @Tags status
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Status page id"
|
||||
// @Success 200 {object} services.StatusSnapshot
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 429 {object} ErrorResponse
|
||||
func getPublicStatusPage(c *gin.Context) {
|
||||
pageID := c.Param("pageId")
|
||||
inst, ok := publicStatusInstance(c)
|
||||
if !ok {
|
||||
// Every 404 on this route is indistinguishable to the caller by
|
||||
// design, so the log is the only place the three reasons are told
|
||||
// apart. It carries no monitor data and no page contents.
|
||||
log.Printf("public status: 404 page=%q reason=no_instance", pageID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
snap, err := services.PublicStatusSnapshot(inst.InstanceID, pageID)
|
||||
if errors.Is(err, services.ErrPageNotFound) {
|
||||
log.Printf("public status: 404 page=%q instance=%s reason=page_missing_or_unpublished", pageID, inst.InstanceID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("public status: 500 page=%q instance=%s: %v", pageID, inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
// Public and cacheable, but only briefly: an intermediary holding this for
|
||||
// minutes would show a resolved incident as ongoing.
|
||||
c.Header("Cache-Control", "public, max-age=30")
|
||||
c.JSON(http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// publicStatusInstance resolves which instance a public request is for.
|
||||
//
|
||||
// The browser never reaches this handler directly: the request arrives from
|
||||
// the Next server, which forwards the visitor's host in X-Forwarded-Host
|
||||
// because the Host header cannot be set on a fetch (undici drops it silently,
|
||||
// as a forbidden header name). That makes X-Forwarded-Host a tenant selector,
|
||||
// so it is honoured only when the machine that opened the connection is one of
|
||||
// the configured trusted proxies.
|
||||
//
|
||||
// When the resulting host names no slug at all — vantage.acme.com,
|
||||
// status.acme.com, a bare IP — and the deployment is not cloud, the single
|
||||
// instance of that install is used. A self-hosted install has exactly one, and
|
||||
// without this every self-hosted status page 404s forever. More than one is a
|
||||
// refusal rather than a guess.
|
||||
func publicStatusInstance(c *gin.Context) (*models.Instance, bool) {
|
||||
// Host resolution is where this route fails silently: an untrusted peer
|
||||
// means X-Forwarded-Host is ignored and the request host is the Go
|
||||
// service's own name, which names no slug. Log the inputs and the branch
|
||||
// taken, so the 404 says which of the four it was.
|
||||
host := c.Request.Host
|
||||
xfh := firstForwarded(c.GetHeader("X-Forwarded-Host"))
|
||||
trusted := trustedPeer(c)
|
||||
if trusted && xfh != "" {
|
||||
host = xfh
|
||||
}
|
||||
log.Printf("public status: resolve peer=%s trusted=%t request_host=%q x_forwarded_host=%q using_host=%q slug=%q",
|
||||
c.RemoteIP(), trusted, c.Request.Host, xfh, host, auth.HostSlug(host))
|
||||
|
||||
if inst, ok := auth.InstanceForHost(host); ok {
|
||||
return inst, true
|
||||
}
|
||||
if slug := auth.HostSlug(host); slug != "" {
|
||||
// The host named an instance and that instance does not exist.
|
||||
log.Printf("public status: no instance for slug=%q (host=%q)", slug, host)
|
||||
return nil, false
|
||||
}
|
||||
if services.DeploymentMode() == license.DeploymentCloud {
|
||||
log.Printf("public status: host %q names no slug and deployment is cloud, refusing to guess", host)
|
||||
return nil, false
|
||||
}
|
||||
inst, ok := auth.SoleInstance()
|
||||
if !ok {
|
||||
log.Printf("public status: host %q names no slug and this deployment has no single instance", host)
|
||||
}
|
||||
return inst, ok
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// tokenRateLimit is per token per minute. It is not the general API
|
||||
// rate-limiting project: it is only enough that a runaway script cannot take an
|
||||
// instance down, and cookie sessions are deliberately untouched.
|
||||
const tokenRateLimit = 600
|
||||
|
||||
// RateLimitTokens counts requests per token in a one-minute fixed window.
|
||||
//
|
||||
// A fixed window rather than a sliding one because the cost of a burst at a
|
||||
// boundary is a script running twice as fast for one second, and a sliding
|
||||
// window is a sorted set per token for that.
|
||||
func RateLimitTokens() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !auth.IsToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
window := time.Now().UTC().Unix() / 60
|
||||
key := "vantage:tokenrate:" + auth.TokenID(c) + ":" + strconv.FormatInt(window, 10)
|
||||
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
// Redis is already required for sessions, so it being down is a
|
||||
// larger problem than this. Do not turn it into a second outage.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
|
||||
}
|
||||
if count > tokenRateLimit {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded for this API token",
|
||||
"code": "rate_limited",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// routeScopes maps a registered gin route — "<METHOD> <full path pattern>" — to
|
||||
// the scope an API token must hold to reach it.
|
||||
//
|
||||
// It is keyed on the route pattern rather than declared per route with a
|
||||
// decorator, because a route registered without a decorator would be
|
||||
// unguarded. AssertScopeMapComplete refuses to boot if any /api route is
|
||||
// missing here, so the failure lands at deploy rather than as a surprise 403
|
||||
// in production.
|
||||
//
|
||||
// GET is read, everything else is write. The exceptions are written out rather
|
||||
// than derived, because two of them are not obvious: reading a private key is
|
||||
// still reading a key, and reading a container's logs is a write-level action
|
||||
// because container output is arbitrary and cannot be masked.
|
||||
var routeScopes = map[string]string{
|
||||
"GET /api/license": "settings:read",
|
||||
"POST /api/license": "settings:write",
|
||||
|
||||
"GET /api/servers": "servers:read",
|
||||
"GET /api/servers/tags": "servers:read",
|
||||
"POST /api/servers": "servers:write",
|
||||
"GET /api/servers/new": "servers:write",
|
||||
"POST /api/servers/new": "servers:write",
|
||||
"GET /api/servers/:id": "servers:read",
|
||||
"DELETE /api/servers/:id": "servers:write",
|
||||
"POST /api/servers/:id/generate-key": "keys:write",
|
||||
"POST /api/servers/:id/update-agent": "servers:write",
|
||||
"POST /api/servers/:id/apply-updates": "servers:write",
|
||||
"PUT /api/servers/:id/tags": "servers:write",
|
||||
|
||||
"GET /api/agent/latest-version": "servers:read",
|
||||
"GET /api/audit": "settings:read",
|
||||
|
||||
"GET /api/settings": "settings:read",
|
||||
"PUT /api/settings": "settings:write",
|
||||
"POST /api/settings/secrets-token": "settings:write",
|
||||
|
||||
"GET /api/secrets": "secrets:read",
|
||||
"POST /api/secrets": "secrets:write",
|
||||
"GET /api/secrets/:group": "secrets:read",
|
||||
"PUT /api/secrets/:group": "secrets:write",
|
||||
"POST /api/secrets/:group/reveal": "secrets:read",
|
||||
"DELETE /api/secrets/:group": "secrets:write",
|
||||
"DELETE /api/secrets/:group/:key": "secrets:write",
|
||||
|
||||
"GET /api/keys": "keys:read",
|
||||
"POST /api/keys": "keys:write",
|
||||
"GET /api/keys/:id": "keys:read",
|
||||
"GET /api/keys/:id/private-key": "keys:read",
|
||||
"DELETE /api/keys/:id": "keys:write",
|
||||
"POST /api/keys/:id/assign": "keys:write",
|
||||
"DELETE /api/keys/:id/assign/:serverId": "keys:write",
|
||||
|
||||
"POST /api/console/connect": "servers:write",
|
||||
"GET /api/console/tunnel": "servers:write",
|
||||
|
||||
// Workflow, step and run routes, registered by registerWorkflowRoutes.
|
||||
"GET /api/steps": "workflows:read",
|
||||
"POST /api/steps": "workflows:write",
|
||||
"PUT /api/steps/:id": "workflows:write",
|
||||
"DELETE /api/steps/:id": "workflows:write",
|
||||
"GET /api/steps/:id/export": "workflows:read",
|
||||
"POST /api/steps/import": "workflows:write",
|
||||
"POST /api/steps/seed-defaults": "workflows:write",
|
||||
"GET /api/steps/usage": "workflows:read",
|
||||
"POST /api/steps/parse": "workflows:write",
|
||||
|
||||
"GET /api/workflows": "workflows:read",
|
||||
"POST /api/workflows": "workflows:write",
|
||||
"GET /api/workflows/:id": "workflows:read",
|
||||
"PUT /api/workflows/:id": "workflows:write",
|
||||
"DELETE /api/workflows/:id": "workflows:write",
|
||||
"POST /api/workflows/:id/run": "workflows:write",
|
||||
"GET /api/workflows/:id/runs": "workflows:read",
|
||||
"PUT /api/workflows/:id/schedule": "workflows:write",
|
||||
"GET /api/workflows/:id/schedule/preview": "workflows:read",
|
||||
|
||||
"GET /api/runs/:runId": "workflows:read",
|
||||
"POST /api/runs/:runId/cancel": "workflows:write",
|
||||
"GET /api/runs/:runId/servers/:serverId/logs": "workflows:read",
|
||||
"GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read",
|
||||
|
||||
// Monitor and incident routes, registered by registerMonitorRoutes.
|
||||
"GET /api/monitors": "monitors:read",
|
||||
"POST /api/monitors": "monitors:write",
|
||||
"GET /api/monitors/:id": "monitors:read",
|
||||
"PUT /api/monitors/:id": "monitors:write",
|
||||
"DELETE /api/monitors/:id": "monitors:write",
|
||||
"GET /api/monitors/:id/incidents": "monitors:read",
|
||||
"GET /api/monitors/:id/uptime": "monitors:read",
|
||||
"GET /api/monitors/:id/samples": "monitors:read",
|
||||
|
||||
// Channel routes, registered by registerChannelRoutes. Channels exist to
|
||||
// serve alerts, so they share the monitors scope rather than getting their
|
||||
// own resource.
|
||||
"GET /api/channels": "monitors:read",
|
||||
"POST /api/channels": "monitors:write",
|
||||
"PUT /api/channels/:id": "monitors:write",
|
||||
"DELETE /api/channels/:id": "monitors:write",
|
||||
"POST /api/channels/:id/test": "monitors:write",
|
||||
|
||||
// Instance user management and SSO configuration live on the /settings
|
||||
// page in web/ (the Access group), so both share the settings scope.
|
||||
"GET /api/instance/users": "settings:read",
|
||||
"POST /api/instance/users": "settings:write",
|
||||
"PUT /api/instance/users/:id/role": "settings:write",
|
||||
"DELETE /api/instance/users/:id": "settings:write",
|
||||
|
||||
"GET /api/auth/providers": "settings:read",
|
||||
"POST /api/auth/providers": "settings:write",
|
||||
"PUT /api/auth/providers/:id": "settings:write",
|
||||
"DELETE /api/auth/providers/:id": "settings:write",
|
||||
"POST /api/auth/providers/:id/test": "settings:write",
|
||||
"POST /api/auth/providers/:id/ack-notice": "settings:write",
|
||||
"GET /api/auth/presets": "settings:read",
|
||||
|
||||
"GET /api/vulnerabilities": "vulns:read",
|
||||
"GET /api/vulnerabilities/summary": "vulns:read",
|
||||
"POST /api/vulnerabilities/rescan": "vulns:write",
|
||||
"POST /api/vulnerabilities/:id/accept": "vulns:write",
|
||||
"DELETE /api/vulnerabilities/:id/accept": "vulns:write",
|
||||
"GET /api/servers/:id/vulnerabilities": "vulns:read",
|
||||
"GET /api/servers/:id/packages": "vulns:read",
|
||||
"GET /api/packages/search": "vulns:read",
|
||||
"GET /api/vuln-rules": "vulns:read",
|
||||
"POST /api/vuln-rules": "vulns:write",
|
||||
"PUT /api/vuln-rules/:id": "vulns:write",
|
||||
"DELETE /api/vuln-rules/:id": "vulns:write",
|
||||
|
||||
"GET /api/workloads": "workloads:read",
|
||||
"GET /api/servers/:id/workloads": "workloads:read",
|
||||
"POST /api/servers/:id/workloads/refresh": "workloads:read",
|
||||
"POST /api/servers/:id/workloads/:wid/action": "workloads:write",
|
||||
"GET /api/servers/:id/workloads/:wid/logs": "workloads:write",
|
||||
|
||||
"GET /api/tokens": "settings:read",
|
||||
"GET /api/tokens/scopes": "settings:read",
|
||||
"POST /api/tokens": "settings:write",
|
||||
"DELETE /api/tokens/:id": "settings:write",
|
||||
|
||||
// The generated OpenAPI document and its Scalar reference page. Read-only,
|
||||
// so they share the settings:read scope with the rest of the docs a token
|
||||
// can already see about its own instance.
|
||||
"GET /api/openapi.json": "settings:read",
|
||||
"GET /api/docs": "settings:read",
|
||||
"GET /api/docs/scalar.js": "settings:read",
|
||||
|
||||
// Status pages. Reading is status:read even though the pages themselves
|
||||
// are public, because these routes read the unpublished ones too.
|
||||
"GET /api/status-pages": "status:read",
|
||||
"POST /api/status-pages": "status:write",
|
||||
"GET /api/status-pages/:pageId": "status:read",
|
||||
"PUT /api/status-pages/:pageId": "status:write",
|
||||
"DELETE /api/status-pages/:pageId": "status:write",
|
||||
"GET /api/status-pages/:pageId/incidents": "status:read",
|
||||
"POST /api/status-pages/:pageId/incidents": "status:write",
|
||||
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
|
||||
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
|
||||
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
|
||||
}
|
||||
|
||||
// RequireScopes enforces routeScopes for token-authenticated requests and does
|
||||
// nothing at all for cookie sessions, whose authority is their role.
|
||||
func RequireScopes() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !auth.IsToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
key := c.Request.Method + " " + c.FullPath()
|
||||
required, ok := routeScopes[key]
|
||||
if !ok {
|
||||
// Fail closed. An unmapped route reached by a token is a route
|
||||
// nobody decided the authority for.
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "this endpoint is not available to API tokens",
|
||||
"code": "scope_unmapped",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !services.ScopeSatisfied(auth.Scopes(c), required) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("token is missing the %q scope", required),
|
||||
"code": "scope_missing",
|
||||
"required_scope": required,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AssertScopeMapComplete fails boot when a registered /api route has no scope.
|
||||
//
|
||||
// Without it, adding a route silently makes it unreachable by every token, and
|
||||
// the report arrives as a customer asking why their script gets 403.
|
||||
func AssertScopeMapComplete(r *gin.Engine) error {
|
||||
var missing []string
|
||||
for _, route := range r.Routes() {
|
||||
if !strings.HasPrefix(route.Path, "/api/") {
|
||||
continue
|
||||
}
|
||||
// The ESO endpoint keeps its own bearer scheme and is deliberately
|
||||
// outside the token vocabulary.
|
||||
if route.Path == "/api/secrets/:group/values" {
|
||||
continue
|
||||
}
|
||||
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
|
||||
missing = append(missing, route.Method+" "+route.Path)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return fmt.Errorf("routes missing from the API token scope map: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
func validName(s string) bool {
|
||||
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
|
||||
}
|
||||
|
||||
const ctxSecretsInstanceKey = "km_secrets_instance"
|
||||
|
||||
func secretsReadAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
const prefix = "Bearer "
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if len(authHeader) <= len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
instanceID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSecretsInstanceKey, instanceID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// esoGetGroup godoc
|
||||
//
|
||||
// @Summary Read a secret group's values (ESO)
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security esoAuth
|
||||
// @Router /secrets/{group}/values [get]
|
||||
func esoGetGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
|
||||
instanceID := c.GetString(ctxSecretsInstanceKey)
|
||||
if instanceID == "" {
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
values, err := services.GetSecretGroupDecrypted(instanceID, group)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, values)
|
||||
}
|
||||
|
||||
// listSecretGroups godoc
|
||||
//
|
||||
// @Summary List secret groups
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.GroupSummary
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets [get]
|
||||
func listSecretGroups(c *gin.Context) {
|
||||
groups, err := services.ListSecretGroups(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
// createSecretGroup godoc
|
||||
//
|
||||
// @Summary Create a secret group
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{group=string,values=map[string]string} true "Group and its initial key/value pairs"
|
||||
// @Success 201 {object} GroupResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets [post]
|
||||
func createSecretGroup(c *gin.Context) {
|
||||
var body struct {
|
||||
Group string `json:"group" binding:"required"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !validName(body.Group) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
|
||||
return
|
||||
}
|
||||
if len(body.Values) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a group must be created with at least one key"})
|
||||
return
|
||||
}
|
||||
for k := range body.Values {
|
||||
if !validName(k) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := services.UpsertSecrets(auth.InstanceID(c), body.Group, body.Values); err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
|
||||
c.JSON(http.StatusCreated, GroupResponse{Group: body.Group})
|
||||
}
|
||||
|
||||
// getSecretGroup godoc
|
||||
//
|
||||
// @Summary Get a secret group's keys
|
||||
// @Description Returns the group's key metadata, not decrypted values. See POST /secrets/{group}/reveal for a value.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} SecretGroupResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [get]
|
||||
func getSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SecretGroupResponse{Group: group, Secrets: secrets})
|
||||
}
|
||||
|
||||
// putSecretGroup godoc
|
||||
//
|
||||
// @Summary Replace a secret group's keys
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param body body map[string]string true "Key/value pairs"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [put]
|
||||
func putSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if !validName(group) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
|
||||
return
|
||||
}
|
||||
var values map[string]string
|
||||
if err := c.ShouldBindJSON(&values); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
|
||||
return
|
||||
}
|
||||
for k := range values {
|
||||
if !validName(k) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := services.UpsertSecrets(auth.InstanceID(c), group, values); err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// revealSecret godoc
|
||||
//
|
||||
// @Summary Reveal a secret value
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param body body object{key=string} true "Key to reveal"
|
||||
// @Success 200 {object} RevealSecretResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group}/reveal [post]
|
||||
func revealSecret(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
value, err := services.RevealSecret(auth.InstanceID(c), group, body.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
|
||||
c.JSON(http.StatusOK, RevealSecretResponse{Value: value})
|
||||
}
|
||||
|
||||
// deleteSecretKey godoc
|
||||
//
|
||||
// @Summary Delete a key from a secret group
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param key path string true "Key name"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group}/{key} [delete]
|
||||
func deleteSecretKey(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
key := c.Param("key")
|
||||
if err := services.DeleteSecret(auth.InstanceID(c), group, key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// deleteSecretGroup godoc
|
||||
//
|
||||
// @Summary Delete a secret group
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [delete]
|
||||
func deleteSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// rotateSecretsToken godoc
|
||||
//
|
||||
// @Summary Rotate the ESO read token
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} SecretsTokenResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings/secrets-token [post]
|
||||
func rotateSecretsToken(c *gin.Context) {
|
||||
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
c.JSON(http.StatusOK, SecretsTokenResponse{Token: token})
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerStatusPageRoutes(g *gin.RouterGroup) {
|
||||
// Owner or admin throughout: publishing a page is speaking to the public
|
||||
// in the instance's name. The feature gate sits alongside the role gate so
|
||||
// authoring and serving are gated by the same licence feature.
|
||||
sp := g.Group("/status-pages")
|
||||
sp.Use(auth.RequireRole("owner", "admin"), RequireFeature(license.FeatureStatusPages))
|
||||
|
||||
sp.GET("", listStatusPages)
|
||||
sp.POST("", createStatusPage)
|
||||
sp.GET("/:pageId", getStatusPage)
|
||||
sp.PUT("/:pageId", updateStatusPage)
|
||||
sp.DELETE("/:pageId", deleteStatusPage)
|
||||
sp.GET("/:pageId/incidents", listStatusIncidents)
|
||||
sp.POST("/:pageId/incidents", createStatusIncident)
|
||||
sp.PUT("/:pageId/incidents/:incidentId", updateStatusIncident)
|
||||
sp.DELETE("/:pageId/incidents/:incidentId", deleteStatusIncident)
|
||||
sp.POST("/:pageId/incidents/:incidentId/updates", appendStatusIncidentUpdate)
|
||||
}
|
||||
|
||||
// statusPageError maps the service errors onto codes once, so ten handlers do
|
||||
// not each invent their own. services.ErrPageInvalid covers every validation
|
||||
// failure in the status page and incident services — a missing title or an
|
||||
// invalid incident status is a 400, not a 500.
|
||||
func statusPageError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrPageNotFound), errors.Is(err, services.ErrIncidentNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrPageIDTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidPageID), errors.Is(err, services.ErrPageInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// listStatusPages godoc
|
||||
//
|
||||
// @Summary List status pages
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.StatusPage
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages [get]
|
||||
func listStatusPages(c *gin.Context) {
|
||||
pages, err := services.ListStatusPages(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, pages)
|
||||
}
|
||||
|
||||
// createStatusPage godoc
|
||||
//
|
||||
// @Summary Create a status page
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.StatusPage true "Status page"
|
||||
// @Success 201 {object} models.StatusPage
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages [post]
|
||||
func createStatusPage(c *gin.Context) {
|
||||
var p models.StatusPage
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateStatusPage(auth.InstanceID(c), &p)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_created", actorFromCtx(c), "", "",
|
||||
"Status page '"+created.PageID+"' created")
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// getStatusPage godoc
|
||||
//
|
||||
// @Summary Get a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 200 {object} models.StatusPage
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [get]
|
||||
func getStatusPage(c *gin.Context) {
|
||||
page, err := services.GetStatusPage(auth.InstanceID(c), c.Param("pageId"))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
|
||||
// updateStatusPage godoc
|
||||
//
|
||||
// @Summary Update a status page
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param body body models.StatusPage true "Status page"
|
||||
// @Success 200 {object} models.StatusPage
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [put]
|
||||
func updateStatusPage(c *gin.Context) {
|
||||
var p models.StatusPage
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.UpdateStatusPage(auth.InstanceID(c), c.Param("pageId"), &p)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_updated", actorFromCtx(c), "", "",
|
||||
"Status page '"+updated.PageID+"' updated")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStatusPage godoc
|
||||
//
|
||||
// @Summary Delete a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [delete]
|
||||
func deleteStatusPage(c *gin.Context) {
|
||||
if err := services.DeleteStatusPage(auth.InstanceID(c), c.Param("pageId")); err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_deleted", actorFromCtx(c), "", "",
|
||||
"Status page '"+c.Param("pageId")+"' deleted")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// listStatusIncidents godoc
|
||||
//
|
||||
// @Summary List authored incidents for a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 200 {array} models.StatusIncident
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents [get]
|
||||
func listStatusIncidents(c *gin.Context) {
|
||||
incs, err := services.ListStatusIncidents(auth.InstanceID(c), c.Param("pageId"))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incs)
|
||||
}
|
||||
|
||||
// createStatusIncident godoc
|
||||
//
|
||||
// @Summary Create an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param body body models.StatusIncident true "Incident"
|
||||
// @Success 201 {object} models.StatusIncident
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents [post]
|
||||
func createStatusIncident(c *gin.Context) {
|
||||
var inc models.StatusIncident
|
||||
if err := c.ShouldBindJSON(&inc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// The page in the path is always one of the pages the incident names, so
|
||||
// creating from a page cannot produce an incident that page never shows.
|
||||
if !contains(inc.PageIDs, c.Param("pageId")) {
|
||||
inc.PageIDs = append(inc.PageIDs, c.Param("pageId"))
|
||||
}
|
||||
created, err := services.CreateStatusIncident(auth.InstanceID(c), &inc)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_created", actorFromCtx(c), "", "",
|
||||
"Status "+created.Kind+" '"+created.Title+"' created")
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateStatusIncident godoc
|
||||
//
|
||||
// @Summary Update an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Param body body models.StatusIncident true "Incident"
|
||||
// @Success 200 {object} models.StatusIncident
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId} [put]
|
||||
func updateStatusIncident(c *gin.Context) {
|
||||
var inc models.StatusIncident
|
||||
if err := c.ShouldBindJSON(&inc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.UpdateStatusIncident(auth.InstanceID(c), c.Param("incidentId"), &inc)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_updated", actorFromCtx(c), "", "",
|
||||
"Status "+updated.Kind+" '"+updated.Title+"' updated")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStatusIncident godoc
|
||||
//
|
||||
// @Summary Delete an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId} [delete]
|
||||
func deleteStatusIncident(c *gin.Context) {
|
||||
if err := services.DeleteStatusIncident(auth.InstanceID(c), c.Param("incidentId")); err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_deleted", actorFromCtx(c), "", "",
|
||||
"Status incident '"+c.Param("incidentId")+"' deleted")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// appendStatusIncidentUpdate godoc
|
||||
//
|
||||
// @Summary Post an update to an incident
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Param body body StatusIncidentUpdateRequest true "Update"
|
||||
// @Success 200 {object} models.StatusIncident
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId}/updates [post]
|
||||
func appendStatusIncidentUpdate(c *gin.Context) {
|
||||
var body StatusIncidentUpdateRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.AppendStatusIncidentUpdate(
|
||||
auth.InstanceID(c), c.Param("incidentId"), body.Status, body.Body, actorFromCtx(c))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_update_posted", actorFromCtx(c), "", "",
|
||||
"Update posted to '"+updated.Title+"' ("+body.Status+")")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func elevated(c *gin.Context) bool {
|
||||
r := auth.Role(c)
|
||||
return r == models.RoleOwner || r == models.RoleAdmin
|
||||
}
|
||||
|
||||
// listTokens godoc
|
||||
//
|
||||
// @Summary List API tokens
|
||||
// @Description Returns the caller's own tokens. Owner and admin may pass all=true to see every token in the instance.
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Param all query bool false "Include every token in the instance (owner and admin only)"
|
||||
// @Success 200 {object} ListTokensResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens [get]
|
||||
func listTokens(c *gin.Context) {
|
||||
all := c.Query("all") == "true" && elevated(c)
|
||||
tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ListTokensResponse{Tokens: tokens, All: all})
|
||||
}
|
||||
|
||||
// listTokenScopes godoc
|
||||
//
|
||||
// @Summary List available token scopes
|
||||
// @Description Advertises the scope vocabulary so the UI never hardcodes it.
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Success 200 {object} TokenScopesResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens/scopes [get]
|
||||
func listTokenScopes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, TokenScopesResponse{Scopes: services.AllScopes()})
|
||||
}
|
||||
|
||||
// createToken godoc
|
||||
//
|
||||
// @Summary Create an API token
|
||||
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.
|
||||
// @Tags tokens
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body CreateTokenRequest true "Token parameters"
|
||||
// @Success 201 {object} CreateTokenResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 422 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens [post]
|
||||
func createToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
Scopes []string `json:"scopes" binding:"required"`
|
||||
ExpiresInDays *int `json:"expires_in_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// A token-authenticated request may only mint a token whose scopes are a
|
||||
// subset of its own. Role is capped against the creating *user* below (in
|
||||
// services.CreateAPIToken), but a role cap alone does not confine scopes —
|
||||
// without this, a CI token holding only settings:write could mint a token
|
||||
// holding keys:write and secrets:write, since minting only ever required
|
||||
// settings:write and never checked what the caller itself could reach. A
|
||||
// cookie session skips this: its authority is the user's role, not a
|
||||
// scope list.
|
||||
if auth.IsToken(c) {
|
||||
callerScopes := auth.Scopes(c)
|
||||
var excess []string
|
||||
for _, s := range body.Scopes {
|
||||
if !services.ScopeSatisfied(callerScopes, s) {
|
||||
excess = append(excess, s)
|
||||
}
|
||||
}
|
||||
if len(excess) > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("requested scopes exceed the calling token's own scopes: %v", excess),
|
||||
"code": "scope_confinement",
|
||||
"excess_scopes": excess,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tok, plaintext, err := services.CreateAPIToken(
|
||||
auth.InstanceID(c), auth.UserID(c),
|
||||
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, services.ErrTokenNameTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "name_taken"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenRoleTooHigh):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "role_too_high"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenExpiryPolicy):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error(), "code": "expiry_policy"})
|
||||
return
|
||||
case errors.Is(err, services.ErrInvalidScope):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "invalid_scope"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiry := "no expiry"
|
||||
if tok.ExpiresAt != nil {
|
||||
expiry = "expires " + tok.ExpiresAt.Format("2006-01-02")
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry))
|
||||
|
||||
// The plaintext is returned exactly once and is not stored anywhere.
|
||||
c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok})
|
||||
}
|
||||
|
||||
// revokeToken godoc
|
||||
//
|
||||
// @Summary Revoke an API token
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 200 {object} RevokedResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens/{id} [delete]
|
||||
func revokeToken(c *gin.Context) {
|
||||
requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := services.RevokeAPIToken(auth.InstanceID(c), c.Param("id"), requester)
|
||||
if errors.Is(err, services.ErrTokenNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token '%s' revoked", tok.Name))
|
||||
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TrustedProxies reads TRUSTED_PROXIES, a comma-separated list of CIDRs or
|
||||
// addresses. Unset means trust none: ClientIP() is then the peer address,
|
||||
// which is right for a direct exposure and means every request behind an
|
||||
// un-configured proxy shares one address for rate limiting. That is a visible
|
||||
// failure (one client limited) rather than an invisible one (no limit at all).
|
||||
//
|
||||
// This lives here rather than in main.go because the string has two consumers:
|
||||
// gin's own SetTrustedProxies, which main.go calls with it, and trustedPeer
|
||||
// below, which the public status page uses to decide whether to believe an
|
||||
// X-Forwarded-Host. One variable, one parser.
|
||||
func TrustedProxies() []string {
|
||||
v := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
trustedNetsOnce sync.Once
|
||||
trustedNets []*net.IPNet
|
||||
)
|
||||
|
||||
func parsedTrustedNets() []*net.IPNet {
|
||||
trustedNetsOnce.Do(func() {
|
||||
for _, entry := range TrustedProxies() {
|
||||
if _, n, err := net.ParseCIDR(entry); err == nil {
|
||||
trustedNets = append(trustedNets, n)
|
||||
continue
|
||||
}
|
||||
// A bare address is a /32 or /128.
|
||||
if ip := net.ParseIP(entry); ip != nil {
|
||||
bits := 32
|
||||
if ip.To4() == nil {
|
||||
bits = 128
|
||||
}
|
||||
trustedNets = append(trustedNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
||||
}
|
||||
}
|
||||
})
|
||||
return trustedNets
|
||||
}
|
||||
|
||||
// trustedPeer reports whether the immediate peer is one of the configured
|
||||
// proxies.
|
||||
//
|
||||
// It deliberately uses RemoteIP() rather than ClientIP(): ClientIP() is the
|
||||
// reconstructed *client* address, which is derived from the very headers this
|
||||
// function exists to decide whether to believe. X-Forwarded-Host selects a
|
||||
// tenant on the public status route, so it is only honoured when the machine
|
||||
// that actually opened the connection is trusted to have set it.
|
||||
func trustedPeer(c *gin.Context) bool {
|
||||
nets := parsedTrustedNets()
|
||||
if len(nets) == 0 {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(c.RemoteIP())
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
// ErrorResponse is the shape every failing endpoint answers with. Some also
|
||||
// carry a machine-readable code; it is omitted when absent rather than empty.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// LimitExceededResponse is what a create route answers when a licence cap
|
||||
// would be exceeded.
|
||||
type LimitExceededResponse struct {
|
||||
Error string `json:"error"`
|
||||
Limit string `json:"limit"`
|
||||
Current int `json:"current"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
// LicenceErrorResponse pairs an error with a machine-readable reason rather
|
||||
// than a code — used only on the two licence rejection paths that predate the
|
||||
// error/code convention used everywhere else.
|
||||
type LicenceErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Small, reused acknowledgement shapes. Several unrelated handlers happen to
|
||||
// answer with exactly one of these.
|
||||
type DeletedResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type RevokedResponse struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
type SavedResponse struct {
|
||||
Saved bool `json:"saved"`
|
||||
}
|
||||
|
||||
type AcknowledgedResponse struct {
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
}
|
||||
|
||||
type OKResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type CancelledResponse struct {
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
|
||||
type UpdatedResponse struct {
|
||||
Updated bool `json:"updated"`
|
||||
}
|
||||
|
||||
type MessageResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// --- servers / keys ---
|
||||
|
||||
type TagsResponse struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type CreateServerResponse struct {
|
||||
Server *models.Server `json:"server"`
|
||||
Token string `json:"token"`
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
type NewServerResponse struct {
|
||||
ServerID string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
InstallCommand string `json:"install_command"`
|
||||
InstallCommandPS string `json:"install_command_ps"`
|
||||
}
|
||||
|
||||
// ServerDetailResponse is a server with its resolved key assignments.
|
||||
type ServerDetailResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
}
|
||||
|
||||
type GenerateKeyResponse struct {
|
||||
Message string `json:"message"`
|
||||
CommandID string `json:"command_id"`
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
type PrivateKeyResponse struct {
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
// KeyDetailResponse is a key with its resolved server assignments.
|
||||
type KeyDetailResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
|
||||
type AgentVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type UpdateAgentResponse struct {
|
||||
Message string `json:"message"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type AuditEventsResponse struct {
|
||||
Events []models.AuditEvent `json:"events"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// --- tokens ---
|
||||
|
||||
type ListTokensResponse struct {
|
||||
Tokens []models.APIToken `json:"tokens"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
type TokenScopesResponse struct {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
|
||||
type CreateTokenResponse struct {
|
||||
// Token is the plaintext, returned exactly once and stored nowhere.
|
||||
Token string `json:"token"`
|
||||
Record models.APIToken `json:"record"`
|
||||
}
|
||||
|
||||
// --- secrets ---
|
||||
|
||||
type GroupResponse struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type SecretGroupResponse struct {
|
||||
Group string `json:"group"`
|
||||
Secrets []models.Secret `json:"secrets"`
|
||||
}
|
||||
|
||||
type RevealSecretResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type SecretsTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// --- auth providers ---
|
||||
|
||||
type TestProviderResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// --- licence ---
|
||||
|
||||
type LicencePostResponse struct {
|
||||
State license.State `json:"state"`
|
||||
Tier string `json:"tier"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// --- vulnerabilities ---
|
||||
|
||||
// VulnSummaryResponse's four DB-freshness fields are only present at all when
|
||||
// a vulndb_meta document exists; LastError is separately omitted from that
|
||||
// group when empty, matching the handler's original conditional gin.H.
|
||||
type VulnSummaryResponse struct {
|
||||
Counts map[string]int `json:"counts"`
|
||||
DBVersion *int `json:"db_version,omitempty"`
|
||||
PulledAt *time.Time `json:"pulled_at,omitempty"`
|
||||
LastFullScanAt *time.Time `json:"last_full_scan_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type QueuedResponse struct {
|
||||
Queued int64 `json:"queued"`
|
||||
}
|
||||
|
||||
type ReportedResponse struct {
|
||||
Reported bool `json:"reported"`
|
||||
}
|
||||
|
||||
// --- workflows ---
|
||||
|
||||
type SeedDefaultsResponse struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
}
|
||||
|
||||
type RunWorkflowResponse struct {
|
||||
RunID string `json:"run_id"`
|
||||
}
|
||||
|
||||
type ScheduleResponse struct {
|
||||
Schedule models.Schedule `json:"schedule"`
|
||||
NextRunAt *time.Time `json:"next_run_at"`
|
||||
}
|
||||
|
||||
type OccurrencesResponse struct {
|
||||
Occurrences []time.Time `json:"occurrences"`
|
||||
}
|
||||
|
||||
// --- console ---
|
||||
|
||||
type ConsoleConnectResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Token string `json:"token"`
|
||||
WSPath string `json:"ws_path"`
|
||||
}
|
||||
|
||||
// --- workloads ---
|
||||
|
||||
type WorkloadLogsResponse struct {
|
||||
Text string `json:"text"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// --- status pages ---
|
||||
|
||||
// StatusIncidentUpdateRequest is one post to an incident's timeline. The author
|
||||
// is taken from the session, never from the body.
|
||||
type StatusIncidentUpdateRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// vulnGroup is one CVE across every server it affects.
|
||||
//
|
||||
// The board groups by CVE rather than listing findings flat: the same CVE on
|
||||
// forty servers is one decision, and a flat list makes it look like forty.
|
||||
type vulnGroup struct {
|
||||
CVEID string `json:"cve_id"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title,omitempty"`
|
||||
ServerCount int `json:"server_count"`
|
||||
Findings []models.VulnFinding `json:"findings"`
|
||||
}
|
||||
|
||||
// listVulnerabilities godoc
|
||||
//
|
||||
// @Summary List vulnerabilities
|
||||
// @Description Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param severity query string false "Filter by severity"
|
||||
// @Param state query string false "Filter by state (default open)"
|
||||
// @Param server query string false "Filter by server ID"
|
||||
// @Param tag query []string false "Filter by tag as key:value, repeatable"
|
||||
// @Param has_fix query bool false "Filter by whether a vendor fix exists"
|
||||
// @Success 200 {array} vulnGroup
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities [get]
|
||||
func listVulnerabilities(c *gin.Context) {
|
||||
findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
|
||||
Severity: c.Query("severity"),
|
||||
State: c.DefaultQuery("state", models.FindingOpen),
|
||||
ServerID: c.Query("server"),
|
||||
Tags: tagsFromQuery(c),
|
||||
HasFix: hasFixFromQuery(c),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, groupByCVE(findings))
|
||||
}
|
||||
|
||||
// groupByCVE collapses findings into one row per CVE, most severe first.
|
||||
func groupByCVE(findings []models.VulnFinding) []vulnGroup {
|
||||
index := map[string]*vulnGroup{}
|
||||
order := []string{}
|
||||
|
||||
for _, f := range findings {
|
||||
g, ok := index[f.CVEID]
|
||||
if !ok {
|
||||
g = &vulnGroup{CVEID: f.CVEID, Severity: f.Severity, Title: f.Title}
|
||||
index[f.CVEID] = g
|
||||
order = append(order, f.CVEID)
|
||||
}
|
||||
// Several servers can disagree on severity when their distributions
|
||||
// rate the same CVE differently. The highest is shown, because that is
|
||||
// the one deciding whether anyone acts.
|
||||
if models.SeverityRank(f.Severity) > models.SeverityRank(g.Severity) {
|
||||
g.Severity = f.Severity
|
||||
}
|
||||
g.Findings = append(g.Findings, f)
|
||||
}
|
||||
|
||||
out := make([]vulnGroup, 0, len(order))
|
||||
for _, id := range order {
|
||||
g := index[id]
|
||||
servers := map[string]bool{}
|
||||
for _, f := range g.Findings {
|
||||
servers[f.ServerID] = true
|
||||
}
|
||||
g.ServerCount = len(servers)
|
||||
out = append(out, *g)
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ri, rj := models.SeverityRank(out[i].Severity), models.SeverityRank(out[j].Severity)
|
||||
if ri != rj {
|
||||
return ri > rj
|
||||
}
|
||||
return out[i].ServerCount > out[j].ServerCount
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
|
||||
// or malformed value, is no filter — a filter nobody asked for must never hide
|
||||
// findings, and the wrong direction here hides the unfixable ones.
|
||||
func hasFixFromQuery(c *gin.Context) *bool {
|
||||
switch c.Query("has_fix") {
|
||||
case "true":
|
||||
v := true
|
||||
return &v
|
||||
case "false":
|
||||
v := false
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tagsFromQuery reads repeated tag=key:value parameters.
|
||||
func tagsFromQuery(c *gin.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, raw := range c.QueryArray("tag") {
|
||||
k, v, ok := strings.Cut(raw, ":")
|
||||
if !ok || k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// vulnerabilitySummary godoc
|
||||
//
|
||||
// @Summary Get vulnerability counts and database freshness
|
||||
// @Description Counts travel with the database version and pull time, since a fleet scanned against a stale database must say so wherever its findings are read.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {object} VulnSummaryResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/summary [get]
|
||||
func vulnerabilitySummary(c *gin.Context) {
|
||||
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp := VulnSummaryResponse{Counts: counts}
|
||||
|
||||
// Database freshness travels with the counts rather than living in
|
||||
// settings: a fleet scanned against a three-week-old database must say so
|
||||
// wherever its findings are read, not somewhere the reader has to go and
|
||||
// look for it.
|
||||
if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
|
||||
resp.DBVersion = &meta.DBVersion
|
||||
resp.PulledAt = &meta.PulledAt
|
||||
resp.LastFullScanAt = &meta.LastFullScanAt
|
||||
resp.LastError = meta.LastError
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// rescanVulnerabilities godoc
|
||||
//
|
||||
// @Summary Queue the fleet for a vulnerability rescan
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {object} QueuedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/rescan [post]
|
||||
func rescanVulnerabilities(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
|
||||
n, err := services.MarkInstanceForRescan(instanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
|
||||
"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
|
||||
c.JSON(http.StatusOK, QueuedResponse{Queued: n})
|
||||
}
|
||||
|
||||
type acceptFindingRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
|
||||
// acceptFinding godoc
|
||||
//
|
||||
// @Summary Accept a finding
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Finding ID"
|
||||
// @Param body body acceptFindingRequest true "Reason and expiry"
|
||||
// @Success 200 {object} models.VulnFinding
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/{id}/accept [post]
|
||||
func acceptFinding(c *gin.Context) {
|
||||
var req acceptFindingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Both rejected deliberately. An acceptance with no reason is a dismissal
|
||||
// nobody can audit, and one already expired is a permanent dismissal
|
||||
// wearing an expiry — the graveyard the expiry exists to prevent.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a reason is required"})
|
||||
return
|
||||
}
|
||||
if req.Until.IsZero() || !req.Until.After(time.Now()) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "until must be a future date"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
actor := actorFromCtx(c)
|
||||
|
||||
f, err := services.AcceptFinding(instanceID, c.Param("id"), actor, req.Reason, req.Until)
|
||||
if err != nil {
|
||||
writeFindingError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.accepted", actor, f.ServerID, "",
|
||||
f.CVEID+" on "+f.PackageName+" accepted until "+req.Until.Format(time.RFC3339)+": "+req.Reason)
|
||||
c.JSON(http.StatusOK, f)
|
||||
}
|
||||
|
||||
// unacceptFinding godoc
|
||||
//
|
||||
// @Summary Return an accepted finding to open
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Finding ID"
|
||||
// @Success 200 {object} models.VulnFinding
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/{id}/accept [delete]
|
||||
func unacceptFinding(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
actor := actorFromCtx(c)
|
||||
|
||||
f, err := services.UnacceptFinding(instanceID, c.Param("id"))
|
||||
if err != nil {
|
||||
writeFindingError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.unaccepted", actor, f.ServerID, "",
|
||||
f.CVEID+" on "+f.PackageName+" returned to open")
|
||||
c.JSON(http.StatusOK, f)
|
||||
}
|
||||
|
||||
func writeFindingError(c *gin.Context, err error) {
|
||||
if errors.Is(err, services.ErrFindingNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
// listServerVulnerabilities godoc
|
||||
//
|
||||
// @Summary List a server's vulnerabilities
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {array} models.VulnFinding
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/vulnerabilities [get]
|
||||
func listServerVulnerabilities(c *gin.Context) {
|
||||
findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if findings == nil {
|
||||
findings = []models.VulnFinding{}
|
||||
}
|
||||
c.JSON(http.StatusOK, findings)
|
||||
}
|
||||
|
||||
// getServerPackages godoc
|
||||
//
|
||||
// @Summary Get a server's package inventory
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} models.ServerPackages
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/packages [get]
|
||||
func getServerPackages(c *gin.Context) {
|
||||
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if sp == nil {
|
||||
// Not a 404: an agent that has not reported yet is the normal state for
|
||||
// the first hour after install, and is a different thing from a bad
|
||||
// server id.
|
||||
c.JSON(http.StatusOK, ReportedResponse{Reported: false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sp)
|
||||
}
|
||||
|
||||
// searchPackages godoc
|
||||
//
|
||||
// @Summary Search packages fleet-wide
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param name query string true "Package name"
|
||||
// @Success 200 {array} services.PackageHit
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /packages/search [get]
|
||||
func searchPackages(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
hits, err := services.SearchPackages(auth.InstanceID(c), name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hits)
|
||||
}
|
||||
|
||||
// listVulnRules godoc
|
||||
//
|
||||
// @Summary List vulnerability alert rules
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.VulnAlertRule
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules [get]
|
||||
func listVulnRules(c *gin.Context) {
|
||||
rules, err := services.ListVulnRules(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rules)
|
||||
}
|
||||
|
||||
// createVulnRule godoc
|
||||
//
|
||||
// @Summary Create a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.VulnAlertRule true "Rule to create"
|
||||
// @Success 201 {object} models.VulnAlertRule
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules [post]
|
||||
func createVulnRule(c *gin.Context) {
|
||||
var r models.VulnAlertRule
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
created, err := services.CreateVulnRule(instanceID, &r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_created", actorFromCtx(c), "", "", "rule "+created.Name)
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// updateVulnRule godoc
|
||||
//
|
||||
// @Summary Update a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Rule ID"
|
||||
// @Param body body models.VulnAlertRule true "Rule fields"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules/{id} [put]
|
||||
func updateVulnRule(c *gin.Context) {
|
||||
var r models.VulnAlertRule
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
if err := services.UpdateVulnRule(instanceID, c.Param("id"), &r); err != nil {
|
||||
if errors.Is(err, services.ErrVulnRuleNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "updated"})
|
||||
}
|
||||
|
||||
// deleteVulnRule godoc
|
||||
//
|
||||
// @Summary Delete a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Rule ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules/{id} [delete]
|
||||
func deleteVulnRule(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
|
||||
if errors.Is(err, services.ErrVulnRuleNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/steps", listSteps)
|
||||
g.POST("/steps", createStep)
|
||||
g.PUT("/steps/:id", updateStep)
|
||||
g.DELETE("/steps/:id", deleteStep)
|
||||
g.GET("/steps/:id/export", exportStep)
|
||||
g.POST("/steps/import", importStep)
|
||||
g.POST("/steps/seed-defaults", seedDefaults)
|
||||
g.GET("/steps/usage", stepUsage)
|
||||
g.POST("/steps/parse", parseStep)
|
||||
|
||||
g.GET("/workflows", listWorkflows)
|
||||
g.POST("/workflows", createWorkflow)
|
||||
g.GET("/workflows/:id", getWorkflow)
|
||||
g.PUT("/workflows/:id", updateWorkflow)
|
||||
g.DELETE("/workflows/:id", deleteWorkflow)
|
||||
g.POST("/workflows/:id/run", runWorkflow)
|
||||
g.GET("/workflows/:id/runs", listWorkflowRuns)
|
||||
g.PUT("/workflows/:id/schedule", putWorkflowSchedule)
|
||||
g.GET("/workflows/:id/schedule/preview", previewWorkflowSchedule)
|
||||
|
||||
g.GET("/runs/:runId", getRun)
|
||||
g.POST("/runs/:runId/cancel", cancelRun)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
|
||||
}
|
||||
|
||||
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
|
||||
|
||||
// getServerRunLog godoc
|
||||
//
|
||||
// @Summary Get a run's log for one server
|
||||
// @Description Streams the stored log in pages rather than loading it whole; capped at 200k lines per server-run.
|
||||
// @Tags workflows
|
||||
// @Produce plain
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {string} string "plain-text log"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/servers/{serverId}/logs [get]
|
||||
func getServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if !services.HasServerRunLog(runID, serverID) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
// Streamed in pages rather than read whole. A log capped at 200k lines is
|
||||
// tens of megabytes, and holding that in memory per concurrent download is
|
||||
// how one curious user takes a pod down.
|
||||
var after int64
|
||||
for {
|
||||
lines, last, err := services.ReadServerRunLog(runID, serverID, after, logPageSize)
|
||||
if err != nil || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for _, l := range lines {
|
||||
_, _ = c.Writer.WriteString(l)
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
}
|
||||
c.Writer.Flush()
|
||||
after = last
|
||||
}
|
||||
}
|
||||
|
||||
// logPageSize bounds one read of the log store. Large enough that a normal log
|
||||
// is one or two queries, small enough that no single response buffers much.
|
||||
const logPageSize = 2000
|
||||
|
||||
// streamServerRunLog godoc
|
||||
//
|
||||
// @Summary Stream a run's log for one server (SSE)
|
||||
// @Description Server-sent events; sends new lines every 500ms until the server's run reaches a terminal state.
|
||||
// @Tags workflows
|
||||
// @Produce text/event-stream
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {string} string "text/event-stream"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/servers/{serverId}/logs/stream [get]
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
|
||||
return
|
||||
}
|
||||
|
||||
// The cursor is a sequence number now, not a byte offset: lines come from
|
||||
// the log store, which any pod can read, rather than from a file only this
|
||||
// one has.
|
||||
var after int64
|
||||
sendNew := func() {
|
||||
for {
|
||||
lines, last, err := services.ReadServerRunLog(runID, serverID, after, logPageSize)
|
||||
if err != nil || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
after = last
|
||||
for _, line := range lines {
|
||||
for _, part := range splitSSE([]byte(line)) {
|
||||
_, _ = c.Writer.WriteString("data: " + part + "\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
}
|
||||
flusher.Flush()
|
||||
if len(lines) < logPageSize {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
instanceID := auth.InstanceID(c)
|
||||
for {
|
||||
sendNew()
|
||||
if serverRunTerminal(instanceID, runID, serverID) {
|
||||
sendNew()
|
||||
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serverRunTerminal(instanceID, runID, serverID string) bool {
|
||||
r, err := services.GetRun(instanceID, runID)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, sr := range r.ServerRuns {
|
||||
if sr.ServerID == serverID {
|
||||
switch sr.Status {
|
||||
case "success", "failed", "skipped", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func splitSSE(b []byte) []string {
|
||||
s := strings.ReplaceAll(string(b), "\r", "")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
// listSteps godoc
|
||||
//
|
||||
// @Summary List workflow steps
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.WorkflowStep
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps [get]
|
||||
func listSteps(c *gin.Context) {
|
||||
steps, err := services.ListSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, steps)
|
||||
}
|
||||
|
||||
// stepUsage godoc
|
||||
//
|
||||
// @Summary Count workflows using each step
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/usage [get]
|
||||
func stepUsage(c *gin.Context) {
|
||||
counts, err := services.StepUsageCounts(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
|
||||
// createStep godoc
|
||||
//
|
||||
// @Summary Create a workflow step
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Step to create"
|
||||
// @Success 201 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps [post]
|
||||
func createStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.CreateStep(auth.InstanceID(c), s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// updateStep godoc
|
||||
//
|
||||
// @Summary Update a workflow step
|
||||
// @Description A step with source "default" is read-only and refuses with 409, because seeding rewrites it on every boot.
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Param body body models.WorkflowStep true "Step fields"
|
||||
// @Success 200 {object} UpdatedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id} [put]
|
||||
func updateStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil {
|
||||
if errors.Is(err, services.ErrDefaultStep) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
|
||||
c.JSON(http.StatusOK, UpdatedResponse{Updated: true})
|
||||
}
|
||||
|
||||
// deleteStep godoc
|
||||
//
|
||||
// @Summary Delete a workflow step
|
||||
// @Description A step with source "default" is read-only and refuses with 409.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id} [delete]
|
||||
func deleteStep(c *gin.Context) {
|
||||
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
if errors.Is(err, services.ErrDefaultStep) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// exportStep godoc
|
||||
//
|
||||
// @Summary Export a step as a downloadable JSON document
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Success 200 {object} models.WorkflowStep
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id}/export [get]
|
||||
func exportStep(c *gin.Context) {
|
||||
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id")))
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
// seedDefaults godoc
|
||||
//
|
||||
// @Summary Sync the default step library
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {object} SeedDefaultsResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/seed-defaults [post]
|
||||
func seedDefaults(c *gin.Context) {
|
||||
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
c.JSON(http.StatusOK, SeedDefaultsResponse{Created: created, Updated: updated})
|
||||
}
|
||||
|
||||
const maxStepBodyBytes = 1 << 20
|
||||
|
||||
// importStep godoc
|
||||
//
|
||||
// @Summary Import a step from an exported JSON document
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Exported step document"
|
||||
// @Success 201 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/import [post]
|
||||
func importStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.ImportStepToLibrary(auth.InstanceID(c), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// parseStep godoc
|
||||
//
|
||||
// @Summary Parse a step document without saving it
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Step document to parse"
|
||||
// @Success 200 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/parse [post]
|
||||
func parseStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s, err := services.ParseStepDoc(body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// listWorkflows godoc
|
||||
//
|
||||
// @Summary List workflows
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Workflow
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows [get]
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, wfs)
|
||||
}
|
||||
|
||||
// createWorkflow godoc
|
||||
//
|
||||
// @Summary Create a workflow
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.Workflow true "Workflow to create"
|
||||
// @Success 201 {object} models.Workflow
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows [post]
|
||||
func createWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.CreateWorkflow(auth.InstanceID(c), w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// getWorkflow godoc
|
||||
//
|
||||
// @Summary Get a workflow
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 200 {object} models.Workflow
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [get]
|
||||
func getWorkflow(c *gin.Context) {
|
||||
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, w)
|
||||
}
|
||||
|
||||
// updateWorkflow godoc
|
||||
//
|
||||
// @Summary Update a workflow
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param body body models.Workflow true "Workflow fields"
|
||||
// @Success 200 {object} models.Workflow
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [put]
|
||||
func updateWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteWorkflow godoc
|
||||
//
|
||||
// @Summary Delete a workflow
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [delete]
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// runWorkflow godoc
|
||||
//
|
||||
// @Summary Run a workflow
|
||||
// @Description Snapshots the resolved steps into a WorkflowRun and dispatches to every targeted server.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 202 {object} RunWorkflowResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/run [post]
|
||||
func runWorkflow(c *gin.Context) {
|
||||
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrNoTargets) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
|
||||
c.JSON(http.StatusAccepted, RunWorkflowResponse{RunID: runID})
|
||||
}
|
||||
|
||||
// listWorkflowRuns godoc
|
||||
//
|
||||
// @Summary List a workflow's runs
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param limit query int false "Max runs to return (default 50)"
|
||||
// @Success 200 {array} models.WorkflowRun
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/runs [get]
|
||||
func listWorkflowRuns(c *gin.Context) {
|
||||
limit := int64(50)
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
runs, err := services.ListRuns(auth.InstanceID(c), c.Param("id"), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// getRun godoc
|
||||
//
|
||||
// @Summary Get a run
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} models.WorkflowRun
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId} [get]
|
||||
func getRun(c *gin.Context) {
|
||||
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
// cancelRun godoc
|
||||
//
|
||||
// @Summary Cancel a run
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} CancelledResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/cancel [post]
|
||||
func cancelRun(c *gin.Context) {
|
||||
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, CancelledResponse{Cancelled: true})
|
||||
}
|
||||
|
||||
// putWorkflowSchedule godoc
|
||||
//
|
||||
// @Summary Set a workflow's schedule
|
||||
// @Description Standard 5-field cron and an IANA zone, both validated at save time.
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param body body models.Schedule true "Schedule"
|
||||
// @Success 200 {object} ScheduleResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/schedule [put]
|
||||
func putWorkflowSchedule(c *gin.Context) {
|
||||
var body models.Schedule
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
next, err := services.SetSchedule(instanceID, c.Param("id"), &body)
|
||||
if err != nil {
|
||||
if errors.Is(err, workflowsched.ErrBadSchedule) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
|
||||
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
|
||||
c.JSON(http.StatusOK, ScheduleResponse{Schedule: body, NextRunAt: next})
|
||||
}
|
||||
|
||||
// previewWorkflowSchedule godoc
|
||||
//
|
||||
// @Summary Preview the next occurrences of a cron schedule
|
||||
// @Description Exists so the browser and the scheduler agree on what a cron string means.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param cron query string true "5-field cron expression"
|
||||
// @Param tz query string true "IANA time zone name"
|
||||
// @Success 200 {object} OccurrencesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/schedule/preview [get]
|
||||
func previewWorkflowSchedule(c *gin.Context) {
|
||||
expr := c.Query("cron")
|
||||
tz := c.Query("tz")
|
||||
|
||||
occurrences := make([]time.Time, 0, 3)
|
||||
from := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
next, err := workflowsched.NextOccurrence(expr, tz, from)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
occurrences = append(occurrences, next)
|
||||
from = next
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"occurrences": occurrences})
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getServerWorkloads returns the stored snapshot.
|
||||
//
|
||||
// A server that has never reported answers an empty list rather than 404: the
|
||||
// agent may simply not have got there yet, and 404 reads as "no such server".
|
||||
// getServerWorkloads godoc
|
||||
//
|
||||
// @Summary Get a server's workload snapshot
|
||||
// @Description Returns the stored snapshot. A server that has never reported answers an empty list, not 404.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} models.ServerWorkloads
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads [get]
|
||||
func getServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
if _, err := services.GetServer(instanceID, id); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
sw, err := services.GetWorkloads(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if sw == nil {
|
||||
c.JSON(http.StatusOK, models.ServerWorkloads{
|
||||
ServerID: id,
|
||||
Workloads: []models.Workload{},
|
||||
})
|
||||
return
|
||||
}
|
||||
if sw.Workloads == nil {
|
||||
sw.Workloads = []models.Workload{}
|
||||
}
|
||||
c.JSON(http.StatusOK, sw)
|
||||
}
|
||||
|
||||
// refreshServerWorkloads nudges the agent to report now. It returns no data:
|
||||
// the client refetches the stored document once the agent has written it.
|
||||
// refreshServerWorkloads godoc
|
||||
//
|
||||
// @Summary Request a fresh workload report
|
||||
// @Description Nudges the agent to report now. Returns no data; the client refetches once the agent has written it.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} MessageResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/refresh [post]
|
||||
func refreshServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
s, err := services.GetServer(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DispatchRefreshWorkloads(s.ServerID); err != nil {
|
||||
// Not queued: a command whose owner died must fail loudly, so the
|
||||
// client can show the stored snapshot as stale rather than pretend.
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "refresh requested"})
|
||||
}
|
||||
|
||||
// controlWorkload godoc
|
||||
//
|
||||
// @Summary Start, stop or restart a workload
|
||||
// @Description Owner and admin only. The protected set (vantage-agent.service and the agent's own container) is enforced agent-side and answers 409, not an error.
|
||||
// @Tags workloads
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param wid path string true "Workload ID"
|
||||
// @Param body body object{action=string,kind=string} true "Action (start/stop/restart) and kind (container/unit)"
|
||||
// @Success 200 {object} MessageResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/{wid}/action [post]
|
||||
func controlWorkload(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
wid, ok := workloadIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
switch body.Action {
|
||||
case models.WorkloadStart, models.WorkloadStop, models.WorkloadRestart:
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be start, stop or restart"})
|
||||
return
|
||||
}
|
||||
if body.Kind != models.WorkloadContainer && body.Kind != models.WorkloadUnit {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
|
||||
return
|
||||
}
|
||||
|
||||
s, err := services.GetServer(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
err = services.DispatchControlWorkload(s.ServerID, body.Kind, wid, body.Action)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrAgentNotConnected):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
case services.IsWorkloadProtected(err):
|
||||
// Nothing failed — the agent refused, which is the design. 409, not
|
||||
// 500, and the reason is carried through.
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
|
||||
c.JSON(http.StatusOK, MessageResponse{Message: body.Action + " ok"})
|
||||
}
|
||||
|
||||
// getWorkloadLogs godoc
|
||||
//
|
||||
// @Summary Read a workload's logs
|
||||
// @Description Owner and admin only, and audited: container output is arbitrary and cannot be masked. Capped at 500 lines and 256KB, whichever binds first.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param wid path string true "Workload ID"
|
||||
// @Param kind query string false "container or unit (default container)"
|
||||
// @Param tail query int false "Lines to return, clamped to the cap"
|
||||
// @Success 200 {object} WorkloadLogsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/{wid}/logs [get]
|
||||
func getWorkloadLogs(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
wid, ok := workloadIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
kind := c.DefaultQuery("kind", models.WorkloadContainer)
|
||||
if kind != models.WorkloadContainer && kind != models.WorkloadUnit {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clamped rather than refused: a client asking for more than the cap gets
|
||||
// the cap, which is what it would have got anyway.
|
||||
tail, _ := strconv.Atoi(c.Query("tail"))
|
||||
if tail <= 0 || tail > services.MaxWorkloadLogLines {
|
||||
tail = services.MaxWorkloadLogLines
|
||||
}
|
||||
|
||||
s, err := services.GetServer(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
text, truncated, err := services.DispatchWorkloadLogs(s.ServerID, kind, wid, tail)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAgentNotConnected) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Audited because container output is arbitrary and cannot be masked: a
|
||||
// startup banner or a stack trace may carry credentials nobody declared.
|
||||
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
|
||||
|
||||
c.JSON(http.StatusOK, WorkloadLogsResponse{Text: text, Truncated: truncated})
|
||||
}
|
||||
|
||||
// listWorkloads godoc
|
||||
//
|
||||
// @Summary Search workloads fleet-wide
|
||||
// @Description Answers the fleet-wide question, which is the reason the snapshot is stored rather than fetched on demand and discarded.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param image query string false "Filter by image name"
|
||||
// @Param stack query string false "Filter by compose stack"
|
||||
// @Param state query string false "Filter by state"
|
||||
// @Success 200 {array} services.WorkloadHit
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workloads [get]
|
||||
func listWorkloads(c *gin.Context) {
|
||||
hits, err := services.SearchWorkloads(auth.InstanceID(c),
|
||||
c.Query("image"), c.Query("stack"), c.Query("state"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hits)
|
||||
}
|
||||
|
||||
// workloadIDParam decodes :wid. Unit names carry dots and '@', so the client
|
||||
// encodes it and this is where it comes back.
|
||||
func workloadIDParam(c *gin.Context) (string, bool) {
|
||||
raw := c.Param("wid")
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workload id"})
|
||||
return "", false
|
||||
}
|
||||
decoded = strings.TrimSpace(decoded)
|
||||
if decoded == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workload id is required"})
|
||||
return "", false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// githubAPIBase is a variable rather than a constant so tests can point it at
|
||||
// an httptest server. Nothing in production reassigns it.
|
||||
var githubAPIBase = "https://api.github.com"
|
||||
|
||||
const (
|
||||
githubAuthURL = "https://github.com/login/oauth/authorize"
|
||||
githubTokenURL = "https://github.com/login/oauth/access_token"
|
||||
)
|
||||
|
||||
type githubEmail struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// githubOAuthConfig builds the OAuth2 config for a GitHub provider. GitHub has
|
||||
// no discovery document, so the endpoints are constants rather than fetched.
|
||||
func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: p.ClientID,
|
||||
ClientSecret: secret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: p.Scopes,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: githubAuthURL,
|
||||
TokenURL: githubTokenURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// selectGitHubEmail requires an address that is both primary and verified.
|
||||
//
|
||||
// Verified alone is not enough: a non-primary address is one the person happens
|
||||
// to have proved, not the one they present as themselves. Primary alone is far
|
||||
// worse — an unverified address is not proof of control at all, and accepting
|
||||
// one would let anyone with a GitHub account claim any address in the instance.
|
||||
func selectGitHubEmail(emails []githubEmail) (string, error) {
|
||||
for _, e := range emails {
|
||||
if e.Primary && e.Verified && e.Email != "" {
|
||||
return e.Email, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no primary verified email address on the GitHub account")
|
||||
}
|
||||
|
||||
func githubGetJSON(ctx context.Context, client *http.Client, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.New("github api returned " + resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// githubIdentity resolves the signed-in GitHub account to an email and a name.
|
||||
//
|
||||
// The name is best-effort: it is cosmetic, and a failing /user must not fail a
|
||||
// sign-in whose identity is already established.
|
||||
func githubIdentity(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (string, string, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
|
||||
var emails []githubEmail
|
||||
if err := githubGetJSON(ctx, client, githubAPIBase+"/user/emails", &emails); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
email, err := selectGitHubEmail(emails)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var user struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := githubGetJSON(ctx, client, githubAPIBase+"/user", &user); err != nil {
|
||||
return email, "", nil
|
||||
}
|
||||
return email, user.Name, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user