mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
2123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f61ec45b | ||
|
|
6aeadcd1d7 | ||
|
|
3a8dadcd6b | ||
|
|
ecc16673eb | ||
|
|
d28dd89039 | ||
|
|
8260a87215 | ||
|
|
361952ada9 | ||
|
|
6630856061 | ||
|
|
a893bd0557 | ||
|
|
f2839ae6a7 | ||
|
|
e58570d79d | ||
|
|
99f6499b2b | ||
|
|
17d01d7fe0 | ||
|
|
8b766930b0 | ||
|
|
c7e403b410 | ||
|
|
cf8ea19856 | ||
|
|
7113d04f8a | ||
|
|
099b810a2f | ||
|
|
7aa0c89244 | ||
|
|
7847ae98c6 | ||
|
|
a90d564931 | ||
|
|
a5c3699ae9 | ||
|
|
7b8048c6ae | ||
|
|
ec95f2ca1f | ||
|
|
aa7dbe67d3 | ||
|
|
a26680f460 | ||
|
|
522b979052 | ||
|
|
808946312a | ||
|
|
741107bf71 | ||
|
|
6962731220 | ||
|
|
062e111c03 | ||
|
|
470c59e197 | ||
|
|
2f929e74c7 | ||
|
|
fc0417ceb9 | ||
|
|
44174a31e0 | ||
|
|
b599fb7354 | ||
|
|
14f96c9fa0 | ||
|
|
6948852992 | ||
|
|
1b01b08c31 | ||
|
|
2281f2b754 | ||
|
|
b5ed802277 | ||
|
|
d1b5eb08ee | ||
|
|
dba5e6e9e9 | ||
|
|
c125e78c5f | ||
|
|
d1cb0ebecf | ||
|
|
9d7a0665c0 | ||
|
|
882bb43125 | ||
|
|
db6c522d60 | ||
|
|
e29442a06a | ||
|
|
e15ea0d5d3 | ||
|
|
2f374d6af2 | ||
|
|
4a356f4ea5 | ||
|
|
c7676d567d | ||
|
|
5ca4f87951 | ||
|
|
1fee8954cc | ||
|
|
f69b770f5e | ||
|
|
856accdced | ||
|
|
92749b4d6e | ||
|
|
f08c2e6729 | ||
|
|
e420bc6324 | ||
|
|
d723fb92d3 | ||
|
|
5b1de5f921 | ||
|
|
7ed48e7b58 | ||
|
|
af712ebdbf | ||
|
|
33d5cd5993 | ||
|
|
f5e1420ee6 | ||
|
|
b37b252b14 | ||
|
|
0097ea89ad | ||
|
|
10e63507f0 | ||
|
|
9ff4d73d5c | ||
|
|
0e3bd7eff4 | ||
|
|
1c89b5f9ab | ||
|
|
cdbbda40a6 | ||
|
|
29a9d608d9 | ||
|
|
a6dc43d5f6 | ||
|
|
c6718754d3 | ||
|
|
afdd033745 | ||
|
|
d5f54ffe8b | ||
|
|
f5ec76c5c8 | ||
|
|
784a1e0611 | ||
|
|
507cb33089 | ||
|
|
b08fa3bdb6 | ||
|
|
018af84d7d | ||
|
|
27b0381a9a | ||
|
|
57cdef4b8d | ||
|
|
36e9d21e3f | ||
|
|
b72b6ab137 | ||
|
|
30b2c8548a | ||
|
|
7c5cce4b3c | ||
|
|
9362c34fcd | ||
|
|
344b3031e9 | ||
|
|
e89c3aa674 | ||
|
|
ddbbf835af | ||
|
|
cb58a63ee3 | ||
|
|
6b1074cfcd | ||
|
|
d6894b5532 | ||
|
|
635c6765d9 | ||
|
|
86f7cc0d58 | ||
|
|
206995645b | ||
|
|
9282cce1d6 | ||
|
|
c005700a7e | ||
|
|
14744abd57 | ||
|
|
66d6c17d2d | ||
|
|
c142d39951 | ||
|
|
1eb2d10dec | ||
|
|
dabaeb8dfa | ||
|
|
2d17d4b73f | ||
|
|
18d78dd6c9 | ||
|
|
499942e3e7 | ||
|
|
ba11a72214 | ||
|
|
12571764bc | ||
|
|
1e13fa032c | ||
|
|
47b21a25d3 | ||
|
|
45a3ba8829 | ||
|
|
03f2914044 | ||
|
|
c8d1ae3e7e | ||
|
|
c5ae9c2c77 | ||
|
|
4e47c00154 | ||
|
|
313a637982 | ||
|
|
fe8ff268df | ||
|
|
bac6d6866a | ||
|
|
579f2c7cc1 | ||
|
|
ddcbeb3ae9 | ||
|
|
e25fc984af | ||
|
|
4cf47b1dee | ||
|
|
95cbd43097 | ||
|
|
09005939bf | ||
|
|
7b612b8b5a | ||
|
|
670d5e8d33 | ||
|
|
1de2e70d41 | ||
|
|
89b57464d2 | ||
|
|
09ef3adf70 | ||
|
|
3dfc15963c | ||
|
|
f6884eb8c4 | ||
|
|
f8b4382a54 | ||
|
|
d78b5a81fb | ||
|
|
89fe9e9f0a | ||
|
|
4291a91dc0 | ||
|
|
4c6bafe255 | ||
|
|
979dbc4b33 | ||
|
|
9309ad844f | ||
|
|
d672ba2068 | ||
|
|
587486ab0c | ||
|
|
40a5e1470d | ||
|
|
058660ec2e | ||
|
|
668bf5e40f | ||
|
|
77f93c638d | ||
|
|
d0c0996b9f | ||
|
|
2cb4d554aa | ||
|
|
76fb8905c9 | ||
|
|
f822df6cce | ||
|
|
45c840b8d3 | ||
|
|
214f3d6406 | ||
|
|
e8d9877b79 | ||
|
|
cae9aa4134 | ||
|
|
7323d41fbe | ||
|
|
e3644c6142 | ||
|
|
415b2da81b | ||
|
|
cc6f5e89b6 | ||
|
|
2ed2cc66ef | ||
|
|
b1bf7837cf | ||
|
|
77229943d1 | ||
|
|
a0369cf49a | ||
|
|
dbbe7b22ab | ||
|
|
166236c2ee | ||
|
|
0371a961d6 | ||
|
|
144a28f544 | ||
|
|
611c29f1f5 | ||
|
|
6540c1e46a | ||
|
|
24bf92a8bf | ||
|
|
7ae984df4b | ||
|
|
e2154629ca | ||
|
|
0bfd48b9db | ||
|
|
d5f34b2ee2 | ||
|
|
88d2b002be | ||
|
|
30a75832f8 | ||
|
|
5059093d29 | ||
|
|
5a69cfe40d | ||
|
|
3d87bbf230 | ||
|
|
633363e190 | ||
|
|
715f2773c3 | ||
|
|
d07dc86376 | ||
|
|
ef7caa40e7 | ||
|
|
7fb8d5fc0a | ||
|
|
3e540ce589 | ||
|
|
6c71f87589 | ||
|
|
a39048ecce | ||
|
|
b538aa2d66 | ||
|
|
57abb20778 | ||
|
|
d117a0cd13 | ||
|
|
ee55f46962 | ||
|
|
9210502e77 | ||
|
|
2fe2600021 | ||
|
|
9b819169d5 | ||
|
|
9631b229b3 | ||
|
|
9372d6cfa5 | ||
|
|
4dbf98163e | ||
|
|
6ec0771297 | ||
|
|
56395945c0 | ||
|
|
8032497045 | ||
|
|
b35364d7fd | ||
|
|
f085fdf918 | ||
|
|
36daba7a34 | ||
|
|
9837ce1197 | ||
|
|
1bc2287baa | ||
|
|
a519bcf705 | ||
|
|
9461b1004f | ||
|
|
3c15f523be | ||
|
|
5bcdcca784 | ||
|
|
83098f98b6 | ||
|
|
5ab35ae6ba | ||
|
|
f0b0064f3d | ||
|
|
4879295f23 | ||
|
|
64e5725331 | ||
|
|
1995198b18 | ||
|
|
5b6fce1a77 | ||
|
|
fa8e443f7b | ||
|
|
08e7530adb | ||
|
|
5687dad177 | ||
|
|
dd2958a458 | ||
|
|
c4b4dfa996 | ||
|
|
d88c454a2c | ||
|
|
8cdfa338e5 | ||
|
|
4da8c57fe3 | ||
|
|
7892aa9485 | ||
|
|
9d9892be6a | ||
|
|
e2b003af24 | ||
|
|
ffca7e0402 | ||
|
|
ec6ddb43a7 | ||
|
|
2f2d444f97 | ||
|
|
42deab67b3 | ||
|
|
1b1be918a9 | ||
|
|
3f2b67f191 | ||
|
|
6a9eea34a0 | ||
|
|
453a0b3ee7 | ||
|
|
2cd20da1ec | ||
|
|
ea4453321d | ||
|
|
acde38b8e7 | ||
|
|
9996e75a34 | ||
|
|
16f96d73ec | ||
|
|
2c89202001 | ||
|
|
fe38dcd294 | ||
|
|
4185ad1b1e | ||
|
|
6916e9da76 | ||
|
|
654f798d25 | ||
|
|
bef282cfee | ||
|
|
d21d8ce9f5 | ||
|
|
342f8b6a5f | ||
|
|
c50a1c6c46 | ||
|
|
535ee098c3 | ||
|
|
b45df89ce4 | ||
|
|
c8118edf36 | ||
|
|
4a0775c4ea | ||
|
|
6fc02dad3e | ||
|
|
dbf2809bd6 | ||
|
|
1d3051cb89 | ||
|
|
59e27524da | ||
|
|
247e7105a2 | ||
|
|
dc3743aecf | ||
|
|
1c5ee5228c | ||
|
|
9d80281b53 | ||
|
|
3b036299d4 | ||
|
|
f70ae68273 | ||
|
|
1353d76e07 | ||
|
|
621a528083 | ||
|
|
a498875591 | ||
|
|
71b54070e8 | ||
|
|
9a0d346ff3 | ||
|
|
32944538e9 | ||
|
|
0b17026eab | ||
|
|
b13d9b9b40 | ||
|
|
b7fca851b8 | ||
|
|
810c3dfe2b | ||
|
|
a1d64e5239 | ||
|
|
fb33ea57b0 | ||
|
|
5b0c763086 | ||
|
|
f3a12c1008 | ||
|
|
ca35e09eaa | ||
|
|
8cf381b0c3 | ||
|
|
654c4f6978 | ||
|
|
edb8362adc | ||
|
|
29fa4aed19 | ||
|
|
41e93858e1 | ||
|
|
8d918d0459 | ||
|
|
3a759fae89 | ||
|
|
985ff3c36a | ||
|
|
908d4f2603 | ||
|
|
4d67569873 | ||
|
|
1aab31a148 | ||
|
|
aedff9a704 | ||
|
|
669f4bddc5 | ||
|
|
1a4eede34d | ||
|
|
0318808db9 | ||
|
|
06f5d3c8c0 | ||
|
|
082407fa51 | ||
|
|
6688ee26db | ||
|
|
beb003b7ad | ||
|
|
6ecfe0f0a1 | ||
|
|
12057db476 | ||
|
|
ff47d8d48a | ||
|
|
ef5f36cc2b | ||
|
|
84022c4d48 | ||
|
|
118f441029 | ||
|
|
20399b004d | ||
|
|
050eb77508 | ||
|
|
1ab4f079c9 | ||
|
|
6c733f7590 | ||
|
|
d7d8db45ba | ||
|
|
8cf9af79da | ||
|
|
e55793c765 | ||
|
|
d8902ea612 | ||
|
|
a04673a90d | ||
|
|
a97acc07fc | ||
|
|
f8000012f7 | ||
|
|
6080f8cc88 | ||
|
|
37df5b93b1 | ||
|
|
e53abdaec2 | ||
|
|
2db32ea97e | ||
|
|
581897ee74 | ||
|
|
9a88f966d8 | ||
|
|
9d9316e434 | ||
|
|
1b697b1111 | ||
|
|
3043982486 | ||
|
|
0bf92ffffc | ||
|
|
f0f87b56a3 | ||
|
|
4148ab1931 | ||
|
|
550cc36760 | ||
|
|
531cf11025 | ||
|
|
79b70f7b5c | ||
|
|
10d369f59c | ||
|
|
2ef7ac79bc | ||
|
|
778cfb1a5c | ||
|
|
764e9fd131 | ||
|
|
387134ca87 | ||
|
|
a0767d957c | ||
|
|
b94ef91d07 | ||
|
|
e7910751d9 | ||
|
|
1d2655432d | ||
|
|
323273ff30 | ||
|
|
fb2009c65b | ||
|
|
e186cc6848 | ||
|
|
615ac99ad7 | ||
|
|
ec36cfbf75 | ||
|
|
7bf228a33c | ||
|
|
3606290ac8 | ||
|
|
e49024d33b | ||
|
|
fdbc2607ec | ||
|
|
c7cc8fd7db | ||
|
|
07efcb5146 | ||
|
|
856605defa | ||
|
|
713010fa0a | ||
|
|
cd2fbeeead | ||
|
|
a4350a482a | ||
|
|
c825375367 | ||
|
|
fc92c4f431 | ||
|
|
b61c590bdb | ||
|
|
7756c0913f | ||
|
|
c34ec7c1ee | ||
|
|
f8778c4a23 | ||
|
|
e0dbb233f7 | ||
|
|
9725f9abae | ||
|
|
5d575f1590 | ||
|
|
d562c594c3 | ||
|
|
ce226a3010 | ||
|
|
644ae9c1bf | ||
|
|
95053f9502 | ||
|
|
8fbda84acb | ||
|
|
03b7d573e0 | ||
|
|
0c3f51bcec | ||
|
|
e3d97b573b | ||
|
|
ac3796af84 | ||
|
|
f9c343eb07 | ||
|
|
e31df5989a | ||
|
|
98fbf029fc | ||
|
|
4d9a648202 | ||
|
|
405ca3e66a | ||
|
|
0355c28683 | ||
|
|
6c33b8d8fb | ||
|
|
a6c6f14b09 | ||
|
|
e558f55cd9 | ||
|
|
88a057b8d9 | ||
|
|
ed27d404ac | ||
|
|
5dda34c66e | ||
|
|
373ebf26d6 | ||
|
|
f65ed2795c | ||
|
|
664c063a06 | ||
|
|
75795c6fbc | ||
|
|
5b332da7d7 | ||
|
|
d9796d502b | ||
|
|
3b0d87b0fd | ||
|
|
3c348dff3a | ||
|
|
ec1783a35c | ||
|
|
427030c5de | ||
|
|
0be380243b | ||
|
|
312583f055 | ||
|
|
33f49ea9b0 | ||
|
|
470cef17cf | ||
|
|
3f5f65eb9a | ||
|
|
6664c2dbb8 | ||
|
|
0099167a6d | ||
|
|
f009fb73c3 | ||
|
|
715a5ed626 | ||
|
|
5cf38d1b35 | ||
|
|
6b707f29a2 | ||
|
|
9ea84f9748 | ||
|
|
c32d043afb | ||
|
|
8fe4d24408 | ||
|
|
e369e4aab1 | ||
|
|
7dc919e8e3 | ||
|
|
1333efdad5 | ||
|
|
cd8de1aa13 | ||
|
|
d6215d9dec | ||
|
|
9a47267545 | ||
|
|
7851503fbc | ||
|
|
c6d373e6aa | ||
|
|
71fcb9c168 | ||
|
|
3976652942 | ||
|
|
7b56546e21 | ||
|
|
85854e4476 | ||
|
|
b50242ab9f | ||
|
|
20b27a13b2 | ||
|
|
598b2fb374 | ||
|
|
ff7988430d | ||
|
|
25da99fac2 | ||
|
|
8616fe6ee2 | ||
|
|
9b8724453b | ||
|
|
01e104d86a | ||
|
|
0acd1de29c | ||
|
|
a25fab371a | ||
|
|
93e2f95c47 | ||
|
|
f10d631a9c | ||
|
|
cfc4894dab | ||
|
|
3f86fdd6bc | ||
|
|
b09d1f1c33 | ||
|
|
2fc604e047 | ||
|
|
e8afa03e45 | ||
|
|
fc2dfb82d2 | ||
|
|
a728c090a9 | ||
|
|
e58621a735 | ||
|
|
b1be370b2e | ||
|
|
cf0d957ac7 | ||
|
|
f127b67e73 | ||
|
|
7f61bb43c7 | ||
|
|
25c49dd804 | ||
|
|
72222d935c | ||
|
|
063d517306 | ||
|
|
02495ce28e | ||
|
|
63936aa110 | ||
|
|
8d4d42a887 | ||
|
|
2316df5c9a | ||
|
|
59d37ae1dd | ||
|
|
3014fd50c6 | ||
|
|
14c4e3a04e | ||
|
|
8f1070a451 | ||
|
|
0b30cc6b0f | ||
|
|
b2f596b8f0 | ||
|
|
01a96fed74 | ||
|
|
46a903aada | ||
|
|
dfa121dd5b | ||
|
|
bc1da3bf3f | ||
|
|
6e0dc3b59e | ||
|
|
4bf5d4c044 | ||
|
|
736fc76345 | ||
|
|
b6b2ca38f4 | ||
|
|
f07eb25cfc | ||
|
|
d2ea437c1c | ||
|
|
7bc7d0f8d8 | ||
|
|
14cf639aba | ||
|
|
55cdab592c | ||
|
|
ee0ec18283 | ||
|
|
f31c9e03e2 | ||
|
|
e50db10439 | ||
|
|
192dc6c20d | ||
|
|
5e1d14f19b | ||
|
|
b8b89d21b7 | ||
|
|
5eddf4f9ee | ||
|
|
c7186e1720 | ||
|
|
4866509938 | ||
|
|
2122660a5c | ||
|
|
5c68ab896a | ||
|
|
f9c8ec41f4 | ||
|
|
b1ed6b24b0 | ||
|
|
c17c78ad4b | ||
|
|
9ec48ab6b9 | ||
|
|
accd250226 | ||
|
|
80a6579766 | ||
|
|
1ca83ca3fb | ||
|
|
a931da0764 | ||
|
|
a61374c595 | ||
|
|
c3136126e5 | ||
|
|
b23d299533 | ||
|
|
b03aae18c3 | ||
|
|
99b6fe468f | ||
|
|
ef77ec04ca | ||
|
|
242081433e | ||
|
|
b86d4e1f0c | ||
|
|
a151f37d63 | ||
|
|
42f7907740 | ||
|
|
e72e25c59c | ||
|
|
1b0440481b | ||
|
|
26d85681f0 | ||
|
|
1dcee77055 | ||
|
|
1ac16005f9 | ||
|
|
2f1cdb6a0b | ||
|
|
ac93851b2a | ||
|
|
400b3125a4 | ||
|
|
2e5ff32e1a | ||
|
|
a0f7074e59 | ||
|
|
7c32be46ca | ||
|
|
6ed2f9bd0a | ||
|
|
778b106023 | ||
|
|
f179ee72f9 | ||
|
|
974def5fef | ||
|
|
e5351b7d9d | ||
|
|
ed83184d55 | ||
|
|
15b6606c82 | ||
|
|
d7411a3104 | ||
|
|
9f138d09e6 | ||
|
|
bf29129a4b | ||
|
|
f6293b6812 | ||
|
|
7e9424008f | ||
|
|
6c5e70ccb1 | ||
|
|
063834e95b | ||
|
|
c76d6b6396 | ||
|
|
6f00e9fc67 | ||
|
|
d336d1a7fa | ||
|
|
cf0af8fa1e | ||
|
|
fd220b6c42 | ||
|
|
3472bb75e7 | ||
|
|
ba65c96c74 | ||
|
|
c54b214657 | ||
|
|
4fcc17114f | ||
|
|
deb5f55786 | ||
|
|
1836c2b652 | ||
|
|
5b7805181b | ||
|
|
f75894acbb | ||
|
|
541cc197c4 | ||
|
|
363d1aba9a | ||
|
|
eb2cf662b7 | ||
|
|
900f8a7163 | ||
|
|
61bdd304b7 | ||
|
|
279735ae7f | ||
|
|
cc2830f6ec | ||
|
|
8dbd730568 | ||
|
|
bb6aa03485 | ||
|
|
6a22488698 | ||
|
|
f1c30439ff | ||
|
|
1123095bb7 | ||
|
|
938f11981d | ||
|
|
6c4e730e60 | ||
|
|
16584067d7 | ||
|
|
6de0fe75a4 | ||
|
|
34f0913ed0 | ||
|
|
5b305c64e1 | ||
|
|
8ad97761e8 | ||
|
|
0f92ef664d | ||
|
|
16a4fd3687 | ||
|
|
3a3fcbe46a | ||
|
|
18d8ea2052 | ||
|
|
6ab08f4014 | ||
|
|
628a3a0d8d | ||
|
|
f52628e00b | ||
|
|
f9d97ececb | ||
|
|
803e555022 | ||
|
|
b1bd727978 | ||
|
|
c2748dc868 | ||
|
|
302620cb94 | ||
|
|
c255f29e98 | ||
|
|
6d1b818414 | ||
|
|
669636d3e4 | ||
|
|
68038c182b | ||
|
|
308cc88ef7 | ||
|
|
30b545785f | ||
|
|
31fade82f6 | ||
|
|
0246ba93dd | ||
|
|
ff7ec8575c | ||
|
|
aa58cb4a05 | ||
|
|
e9b4efc2d4 | ||
|
|
ea76f7bb0b | ||
|
|
8edcbdcb29 | ||
|
|
5249660e07 | ||
|
|
84b99a641a | ||
|
|
4824e4a487 | ||
|
|
ba723ebe48 | ||
|
|
04ba8cbe9e | ||
|
|
84f41dae77 | ||
|
|
82040bfc21 | ||
|
|
6155ffefcc | ||
|
|
bf4279a590 | ||
|
|
77759fac54 | ||
|
|
63a2fd4dcf | ||
|
|
7a19891c60 | ||
|
|
85573d7980 | ||
|
|
ebd59246a8 | ||
|
|
fd27f55fe5 | ||
|
|
69b8b96fb8 | ||
|
|
19d1d36043 | ||
|
|
9f19ca5754 | ||
|
|
2de2a792f6 | ||
|
|
ada690624b | ||
|
|
465476985b | ||
|
|
da5624c98e | ||
|
|
b2f68bbaf7 | ||
|
|
7507af5829 | ||
|
|
5e39801bba | ||
|
|
5ac153a0bb | ||
|
|
c7a5155ce4 | ||
|
|
869c3d3037 | ||
|
|
ef6a11c146 | ||
|
|
21432911de | ||
|
|
eb98340924 | ||
|
|
746af0d93e | ||
|
|
08ac9c5c58 | ||
|
|
bce3bf2b6e | ||
|
|
4ec9ca61cf | ||
|
|
657e6aa672 | ||
|
|
7835840ebd | ||
|
|
6cabcd85aa | ||
|
|
03e436707d | ||
|
|
781bc5ac58 | ||
|
|
86f72da3d9 | ||
|
|
0a2c674ad8 | ||
|
|
0daa8c196b | ||
|
|
98dc5925a5 | ||
|
|
d5d3f09846 | ||
|
|
0e6fc96eb1 | ||
|
|
b052f40ffb | ||
|
|
2aef9d2478 | ||
|
|
8627a18f2e | ||
|
|
21c478be22 | ||
|
|
9d8f7d158b | ||
|
|
c1649fe837 | ||
|
|
d3c8317939 | ||
|
|
7ffe33f867 | ||
|
|
6c2a57f237 | ||
|
|
0f4141ef3f | ||
|
|
37413c0211 | ||
|
|
d1b64b6748 | ||
|
|
c2bcfab7d4 | ||
|
|
392353ffff | ||
|
|
9734be31cf | ||
|
|
905453d62b | ||
|
|
a3b8a99709 | ||
|
|
2e24e5f358 | ||
|
|
40eb3cf6e1 | ||
|
|
549463088c | ||
|
|
f8b5651883 | ||
|
|
de0a880ca6 | ||
|
|
ba4e194cb5 | ||
|
|
1c05a722c1 | ||
|
|
eda94913cf | ||
|
|
3dfafbc379 | ||
|
|
8d1e54eba6 | ||
|
|
6bfd56b54f | ||
|
|
49f952692b | ||
|
|
fde15c9b60 | ||
|
|
06f26cfacf | ||
|
|
5360665432 | ||
|
|
a20ac1d31f | ||
|
|
1bdd300606 | ||
|
|
c56f0198ff | ||
|
|
02fc6bd4ef | ||
|
|
c3a8352d76 | ||
|
|
463576915f | ||
|
|
1db6b9d307 | ||
|
|
b5a02a118f | ||
|
|
ae96d5d61b | ||
|
|
ce1d532e3c | ||
|
|
f27485ec05 | ||
|
|
9616f458de | ||
|
|
3455faf7da | ||
|
|
7ed4b84654 | ||
|
|
0d76a8e478 | ||
|
|
b9612fef9b | ||
|
|
92ae88f1be | ||
|
|
91a5e58cec | ||
|
|
1658925f52 | ||
|
|
bb5a4454a5 | ||
|
|
9fb600df1b | ||
|
|
fff4fe4e20 | ||
|
|
3e4dfd2bac | ||
|
|
8bd82c8c95 | ||
|
|
b59c724455 | ||
|
|
3ee272fd53 | ||
|
|
ab5d1f266f | ||
|
|
906742e3c4 | ||
|
|
0ee45f41e1 | ||
|
|
6d285410c2 | ||
|
|
eaabfb83ed | ||
|
|
ef2953038e | ||
|
|
cc1a63bf01 | ||
|
|
4b2d8cef3c | ||
|
|
df518ad668 | ||
|
|
37b0c00701 | ||
|
|
88f03aaef2 | ||
|
|
ffd8d273c4 | ||
|
|
ef2a96bcc4 | ||
|
|
734717899b | ||
|
|
d2d28c30d9 | ||
|
|
10532e1a55 | ||
|
|
47886abd2b | ||
|
|
d076f64db3 | ||
|
|
60e3ffc402 | ||
|
|
b21be24faa | ||
|
|
3504875922 | ||
|
|
0f6d4b9146 | ||
|
|
6ebd39ed0b | ||
|
|
5c3a1aecbe | ||
|
|
1a45ec9386 | ||
|
|
97133f657f | ||
|
|
b108dc5ea6 | ||
|
|
35cf44b38e | ||
|
|
6412294262 | ||
|
|
01c8592ca6 | ||
|
|
9b5c3ecd23 | ||
|
|
6de684df59 | ||
|
|
8aca1f8b93 | ||
|
|
bb2fc2ec00 | ||
|
|
18566b5837 | ||
|
|
069e1c1e60 | ||
|
|
2c28d9979c | ||
|
|
0efb3d340d | ||
|
|
535039c29e | ||
|
|
93d3de1644 | ||
|
|
4ce056fe45 | ||
|
|
9ad9858ac2 | ||
|
|
adca142d1e | ||
|
|
739e39e1ca | ||
|
|
14ad6e9b75 | ||
|
|
d46d225a90 | ||
|
|
c05d227df2 | ||
|
|
42e723ff7c | ||
|
|
b02d62642a | ||
|
|
8abedecb16 | ||
|
|
d77a572dc7 | ||
|
|
8606455355 | ||
|
|
21e52722e6 | ||
|
|
6673ab6d4a | ||
|
|
d488b1a680 | ||
|
|
b9ac97ebc3 | ||
|
|
e09d3199c1 | ||
|
|
ccfc4cbddc | ||
|
|
41ad422002 | ||
|
|
674cc85005 | ||
|
|
dd2da69361 | ||
|
|
0ee6e393ce | ||
|
|
433a4d3c7d | ||
|
|
049f26c03b | ||
|
|
cf8372c8cb | ||
|
|
f03550415b | ||
|
|
5a710c4f5e | ||
|
|
56901f91ce | ||
|
|
1109c3547c | ||
|
|
d24ead234d | ||
|
|
d816ae5c88 | ||
|
|
8c6e586063 | ||
|
|
c632ec616d | ||
|
|
bd71a46c25 | ||
|
|
e2b5c3acc8 | ||
|
|
e27ca671fd | ||
|
|
614c999871 | ||
|
|
42693c2c52 | ||
|
|
e21cd72181 | ||
|
|
a9e6a7d644 | ||
|
|
ba72770cab | ||
|
|
7530bec7de | ||
|
|
1173a4d9d5 | ||
|
|
aa409a8a9c | ||
|
|
949e251b2e | ||
|
|
4933ae9014 | ||
|
|
1793443b09 | ||
|
|
23a36e37bb | ||
|
|
c9cf1d458a | ||
|
|
d28a389a93 | ||
|
|
7e76c9763d | ||
|
|
9e029462aa | ||
|
|
4523a2c67b | ||
|
|
84c8bc960e | ||
|
|
c4927162b7 | ||
|
|
1ebe0aeadf | ||
|
|
992c58f2bd | ||
|
|
0bf63cc80e | ||
|
|
5fc6dc8019 | ||
|
|
96184caa48 | ||
|
|
12ff87949d | ||
|
|
b75953bf4c | ||
|
|
c733139091 | ||
|
|
331d37be26 | ||
|
|
57ccd44b89 | ||
|
|
d60b6e7454 | ||
|
|
50e4f27276 | ||
|
|
a0f22ae659 | ||
|
|
235f32e10e | ||
|
|
e7b3acdec3 | ||
|
|
f3a367b02d | ||
|
|
c03aebba3f | ||
|
|
4fb8955bc2 | ||
|
|
5dfccdec3e | ||
|
|
8b386b0aac | ||
|
|
9010f0806a | ||
|
|
495795327c | ||
|
|
686311eabf | ||
|
|
e68b843875 | ||
|
|
b46028cb85 | ||
|
|
fa172ecb95 | ||
|
|
431311979a | ||
|
|
d3249485fa | ||
|
|
4c22a819f9 | ||
|
|
f4d66021e4 | ||
|
|
54c5d5803b | ||
|
|
ae138ddb56 | ||
|
|
b72abec2fc | ||
|
|
db4f3fd210 | ||
|
|
230ce5df5f | ||
|
|
ec681335e8 | ||
|
|
aaad113190 | ||
|
|
63681b4be3 | ||
|
|
b347f1816d | ||
|
|
e9efc5c42a | ||
|
|
28c3a5dbe4 | ||
|
|
505d9fd8bc | ||
|
|
932397d1b3 | ||
|
|
1be445423c | ||
|
|
2df9615fb9 | ||
|
|
fe7fb17ff5 | ||
|
|
72d43878ef | ||
|
|
cc3ce8b5d7 | ||
|
|
d4ae6e0e64 | ||
|
|
480579a0d5 | ||
|
|
ba188aea92 | ||
|
|
40b4e52508 | ||
|
|
e2d5fc9dfb | ||
|
|
c92bdfba16 | ||
|
|
83a2609344 | ||
|
|
18d9004f22 | ||
|
|
74c8bfc59f | ||
|
|
3bf7469d30 | ||
|
|
66837b7d7f | ||
|
|
14b182d09b | ||
|
|
9dba6ec1d9 | ||
|
|
a52b513533 | ||
|
|
218ca8e6eb | ||
|
|
2b2754b779 | ||
|
|
952d1c840d | ||
|
|
2207b60834 | ||
|
|
c09bb28d16 | ||
|
|
13e0759d5a | ||
|
|
80054276eb | ||
|
|
517c9e5108 | ||
|
|
576918daa5 | ||
|
|
bbd4338e8e | ||
|
|
e6423a91aa | ||
|
|
78523f122d | ||
|
|
e1df06f06c | ||
|
|
ecfe04f48a | ||
|
|
ff7f27d4f8 | ||
|
|
dd07425d21 | ||
|
|
ba9aa7c1bd | ||
|
|
92fd253cae | ||
|
|
9f6fac418e | ||
|
|
e53a2757eb | ||
|
|
db32b1d982 | ||
|
|
6b1c1e4f50 | ||
|
|
3eb9614e68 | ||
|
|
8087a98c9d | ||
|
|
5643b2c901 | ||
|
|
18eac2dd7a | ||
|
|
0f2a96554f | ||
|
|
0655e868a2 | ||
|
|
4b66cadf15 | ||
|
|
4ee64339ba | ||
|
|
babe328565 | ||
|
|
6447fda852 | ||
|
|
74f7348529 | ||
|
|
bf456450d7 | ||
|
|
91cb2bbbcc | ||
|
|
c0252387b4 | ||
|
|
bd1e155332 | ||
|
|
ab048b8a03 | ||
|
|
e5ce2ac7a4 | ||
|
|
c7641dad0a | ||
|
|
b5e942ca9d | ||
|
|
74c82d9948 | ||
|
|
d18b13a91a | ||
|
|
0d80db8a8d | ||
|
|
8cc6888c5b | ||
|
|
9af1507238 | ||
|
|
c67818ee86 | ||
|
|
6ef6cbade2 | ||
|
|
8df0e1790d | ||
|
|
8b7643e150 | ||
|
|
ef04f4b0fb | ||
|
|
ce02f1ae8c | ||
|
|
c2d0f60784 | ||
|
|
781830a202 | ||
|
|
9dd545353c | ||
|
|
4c7ebf8b8d | ||
|
|
a4a5f70a10 | ||
|
|
9633bce2e1 | ||
|
|
ca341703bc | ||
|
|
cb2ff61bbc | ||
|
|
08b27806a7 | ||
|
|
b59c3a9e3b | ||
|
|
ecf6019ccb | ||
|
|
2a298de971 | ||
|
|
33633637e5 | ||
|
|
8ede01ad4e | ||
|
|
8966fd6aac | ||
|
|
2b8ff8a743 | ||
|
|
97de4ff8a3 | ||
|
|
f6c3ebf7d3 | ||
|
|
56abfdf39d | ||
|
|
9b95fa4d95 | ||
|
|
f341c573eb | ||
|
|
b227669985 | ||
|
|
ce44d35eb6 | ||
|
|
b05f2a270a | ||
|
|
2e701a90c9 | ||
|
|
507f2f8250 | ||
|
|
9533bd7043 | ||
|
|
2b32b9a445 | ||
|
|
3714c211dc | ||
|
|
504c1ccb37 | ||
|
|
d5e64d6ad9 | ||
|
|
9859aec16c | ||
|
|
0e6d7539ad | ||
|
|
d6eb41aa78 | ||
|
|
97997685b5 | ||
|
|
ab0a90de97 | ||
|
|
eeb7995214 | ||
|
|
68d8f86dc6 | ||
|
|
18fe5a4f11 | ||
|
|
26ee1a9958 | ||
|
|
77c2d91eb0 | ||
|
|
b8a65cbdec | ||
|
|
71f9afc526 | ||
|
|
8ca4a10f24 | ||
|
|
66f21de50e | ||
|
|
3e6ce6cf4a | ||
|
|
cadc45c5b8 | ||
|
|
b7b7b4f718 | ||
|
|
4f49dd5943 | ||
|
|
888414c41b | ||
|
|
8f41bc1558 | ||
|
|
a543ca9e07 | ||
|
|
40b9db3545 | ||
|
|
bd4f6b9206 | ||
|
|
776f95b1ab | ||
|
|
12abde2aeb | ||
|
|
965a8c79af | ||
|
|
d33043288d | ||
|
|
a2ad556f2b | ||
|
|
e53d5f07e8 | ||
|
|
40434005c0 | ||
|
|
3330b2ac4c | ||
|
|
be6e49b9c2 | ||
|
|
e59e6c3797 | ||
|
|
6b04a0a3a6 | ||
|
|
4112a8b2ea | ||
|
|
b84e4a96e2 | ||
|
|
e7f8b259ac | ||
|
|
65c361115a | ||
|
|
129c7c90c0 | ||
|
|
82637ad882 | ||
|
|
931c577345 | ||
|
|
9466d92a7a | ||
|
|
0a0a8b31c7 | ||
|
|
208c77a062 | ||
|
|
02d1343436 | ||
|
|
0226e14251 | ||
|
|
923515ab28 | ||
|
|
4d0c654822 | ||
|
|
779877acd0 | ||
|
|
d49b0a8a45 | ||
|
|
5a9f19cbf2 | ||
|
|
9562295d8b | ||
|
|
3c6924238f | ||
|
|
64ad0f694b | ||
|
|
754f672ee2 | ||
|
|
e50ceeba5a | ||
|
|
57910f906d | ||
|
|
8838e9289b | ||
|
|
d3355a8a09 | ||
|
|
c972bbd397 | ||
|
|
fed9bdf01a | ||
|
|
ab2287202d | ||
|
|
b47282fe4c | ||
|
|
a31e237cc3 | ||
|
|
cfa32a2b4d | ||
|
|
2cacf66a37 | ||
|
|
d0981c2fd5 | ||
|
|
3e12c06627 | ||
|
|
74a3df3f1e | ||
|
|
cb894208a1 | ||
|
|
76752beca6 | ||
|
|
e80e7b0cfa | ||
|
|
77051e6245 | ||
|
|
de7be4f15b | ||
|
|
44fb1af287 | ||
|
|
e7a76b0510 | ||
|
|
2881ff097a | ||
|
|
462c3dde79 | ||
|
|
1be703b56e | ||
|
|
5130da9710 | ||
|
|
8440846bae | ||
|
|
831554f11d | ||
|
|
97fb588a4a | ||
|
|
cd994d57d2 | ||
|
|
70f2882a43 | ||
|
|
fa5d26ce38 | ||
|
|
f76bbaab52 | ||
|
|
cde2062618 | ||
|
|
9673fc4c01 | ||
|
|
19ae7c8902 | ||
|
|
eb63838f6a | ||
|
|
232006f71d | ||
|
|
b6bdc08267 | ||
|
|
7e95e769d5 | ||
|
|
f4c79c80ac | ||
|
|
edded777e7 | ||
|
|
7284165f39 | ||
|
|
1604a6d87d | ||
|
|
7d5ad1e70e | ||
|
|
89860bec97 | ||
|
|
ebc1774300 | ||
|
|
122daf0f87 | ||
|
|
149651f831 | ||
|
|
490306c242 | ||
|
|
316b1e3207 | ||
|
|
84c4c2f9c2 | ||
|
|
4d856f3deb | ||
|
|
61bcbe826a | ||
|
|
bdc848b19e | ||
|
|
65e3b6f3da | ||
|
|
97b05e744f | ||
|
|
fbda210b84 | ||
|
|
ed75ae6d56 | ||
|
|
d1ad1815f7 | ||
|
|
b1a3a26815 | ||
|
|
94760dbc14 | ||
|
|
3a318a86b2 | ||
|
|
f4d0d5904a | ||
|
|
25c7bb935e | ||
|
|
f5deed8709 | ||
|
|
fe3a848eb5 | ||
|
|
8f4f4d2d82 | ||
|
|
66a54cc39e | ||
|
|
7ace958710 | ||
|
|
57655bdb25 | ||
|
|
124077a0a1 | ||
|
|
1b570daf72 | ||
|
|
8bcd5b8189 | ||
|
|
63202a63ef | ||
|
|
7e9ca88e00 | ||
|
|
75aa3dc0cc | ||
|
|
6a1da6a5ff | ||
|
|
d88f092dd1 | ||
|
|
b4d17a392a | ||
|
|
c6a408d4e8 | ||
|
|
d19bf71343 | ||
|
|
02f09c2056 | ||
|
|
6a104d5736 | ||
|
|
95af482ffe | ||
|
|
b05264ff74 | ||
|
|
2aab1ea97b | ||
|
|
f1687017e6 | ||
|
|
052de6b96e | ||
|
|
d2b42e91d2 | ||
|
|
d8a9d7eb5e | ||
|
|
d216a9e219 | ||
|
|
21e82abd54 | ||
|
|
18ed9a57c2 | ||
|
|
702dc3ceb4 | ||
|
|
3180ca2bf9 | ||
|
|
69af74b1e0 | ||
|
|
ef9c0ebbd4 | ||
|
|
ca4d0dc819 | ||
|
|
cd1aa92931 | ||
|
|
9fc9c334c9 | ||
|
|
c563c192b7 | ||
|
|
afedd90c80 | ||
|
|
be2e8e594c | ||
|
|
d392681c58 | ||
|
|
5ed8325592 | ||
|
|
ed1d9fdb57 | ||
|
|
c58ce63fc3 | ||
|
|
5eed329916 | ||
|
|
19c8688eb1 | ||
|
|
4317ff78b1 | ||
|
|
6c16f399d4 | ||
|
|
b480f3aaff | ||
|
|
84f312fa4c | ||
|
|
61d5fdb0ec | ||
|
|
995ab302be | ||
|
|
c6ee558180 | ||
|
|
460cd63d3a | ||
|
|
2a3593d9c5 | ||
|
|
bcca8d295a | ||
|
|
d8f68c1d9a | ||
|
|
ab53326865 | ||
|
|
96b857f642 | ||
|
|
fc12cc8a36 | ||
|
|
276d19b63c | ||
|
|
437024cdb1 | ||
|
|
64b41434ce | ||
|
|
24129d7f12 | ||
|
|
d51b44d642 | ||
|
|
e8c55e8f1f | ||
|
|
9179516b19 | ||
|
|
37abfe66f0 | ||
|
|
ae9d4038b1 | ||
|
|
b6f558d10b | ||
|
|
6d994917a0 | ||
|
|
b99f43783c | ||
|
|
7f76eff827 | ||
|
|
be939f7e63 | ||
|
|
a8a87d2b41 | ||
|
|
0a26accca4 | ||
|
|
40e925680c | ||
|
|
f2cdb74ed8 | ||
|
|
7ac8159728 | ||
|
|
3f0fd15395 | ||
|
|
90dbc279fd | ||
|
|
3723f165bc | ||
|
|
3bffecddf2 | ||
|
|
a818af7833 | ||
|
|
187b3a08fb | ||
|
|
2405ccc0f2 | ||
|
|
0fcfcae9c8 | ||
|
|
2de0cbbafc | ||
|
|
a4012ad353 | ||
|
|
e4315fbbf0 | ||
|
|
a10c02ef63 | ||
|
|
66f154a251 | ||
|
|
92813e6122 | ||
|
|
f50f26e599 | ||
|
|
a3094fda53 | ||
|
|
b004a02e4a | ||
|
|
9586f5158e | ||
|
|
f5ace4fd6d | ||
|
|
a05ae94cea | ||
|
|
dff17b6cb1 | ||
|
|
0b2a8fafce | ||
|
|
691ccaaa04 | ||
|
|
26900c8c9e | ||
|
|
226ce45d0d | ||
|
|
ae472d6744 | ||
|
|
89d08d9953 | ||
|
|
c024c782e4 | ||
|
|
84fc1e35e2 | ||
|
|
995be3781a | ||
|
|
ed570155a1 | ||
|
|
680b617b00 | ||
|
|
0a62e4bc77 | ||
|
|
96d40dd21d | ||
|
|
ff83b54c3b | ||
|
|
81ff375bfd | ||
|
|
b0fc6e68ef | ||
|
|
e1a73be2e1 | ||
|
|
cf2c74e8e8 | ||
|
|
2cb01f7a69 | ||
|
|
48deff15c4 | ||
|
|
d6c8c14de7 | ||
|
|
b2266b588e | ||
|
|
fcaafb3939 | ||
|
|
7d569127ae | ||
|
|
981020a5ab | ||
|
|
d9c8119bda | ||
|
|
485d166912 | ||
|
|
5060532c51 | ||
|
|
f29cca72ba | ||
|
|
77640d51a6 | ||
|
|
f0a6fffa87 | ||
|
|
1b24e1c22a | ||
|
|
ab0d766f47 | ||
|
|
41ffb18604 | ||
|
|
b903f8ff7d | ||
|
|
0483d001b4 | ||
|
|
d290a1fdb9 | ||
|
|
2803e9317d | ||
|
|
c5e26a1ed6 | ||
|
|
a2f91b4108 | ||
|
|
664bd98056 | ||
|
|
5bf236957e | ||
|
|
936e1ae37b | ||
|
|
cbfe1d378f | ||
|
|
e5f1f52759 | ||
|
|
edacc5a7d0 | ||
|
|
ed9267562b | ||
|
|
bddae47454 | ||
|
|
3a5922d4ee | ||
|
|
56994d4c29 | ||
|
|
973eb1a614 | ||
|
|
f9f1fa928a | ||
|
|
328ac721ce | ||
|
|
527feb69db | ||
|
|
ba661f1b3c | ||
|
|
4f584a71df | ||
|
|
97cd92a1a2 | ||
|
|
df7b2824a6 | ||
|
|
03ba1f94d7 | ||
|
|
6dd5d2fe14 | ||
|
|
a2649718ea | ||
|
|
9325c2ad9d | ||
|
|
590151f40b | ||
|
|
4b12ec8913 | ||
|
|
74a1e5ad7d | ||
|
|
75b7319465 | ||
|
|
6a608b8e3f | ||
|
|
2ca4b486ec | ||
|
|
c2cdcefdc8 | ||
|
|
893ac18d60 | ||
|
|
74abb50bdc | ||
|
|
f817f856c8 | ||
|
|
3a23eaa572 | ||
|
|
a7fdce493b | ||
|
|
232976c14a | ||
|
|
dc1009798d | ||
|
|
b6d74249a4 | ||
|
|
f72ab383c9 | ||
|
|
f59cf1090d | ||
|
|
1a50c5e112 | ||
|
|
48da062251 | ||
|
|
bbd3c30b0e | ||
|
|
d6c320bf06 | ||
|
|
d53546d56f | ||
|
|
43d891bee1 | ||
|
|
0e0a24862f | ||
|
|
2345df0e38 | ||
|
|
b09fd48eee | ||
|
|
c916e76bd2 | ||
|
|
8eb4c029b2 | ||
|
|
87e44479cc | ||
|
|
9c7757f801 | ||
|
|
1503986d40 | ||
|
|
0bd3e2fa88 | ||
|
|
0f0b9a6118 | ||
|
|
e4f427f921 | ||
|
|
2006a3e678 | ||
|
|
38240f77ff | ||
|
|
6014925e60 | ||
|
|
1dc9b505e4 | ||
|
|
b0ba1f250d | ||
|
|
2e9feaa60a | ||
|
|
8538364930 | ||
|
|
fb2662b877 | ||
|
|
170218bb26 | ||
|
|
582504d11a | ||
|
|
88d8a8b79f | ||
|
|
8e4fc40be5 | ||
|
|
01a982bdf6 | ||
|
|
daf33a82a6 | ||
|
|
cc5a44e373 | ||
|
|
8e0f8003a9 | ||
|
|
54a8312e46 | ||
|
|
85f48123b6 | ||
|
|
fdea51c7b1 | ||
|
|
870bb19798 | ||
|
|
dbec85344d | ||
|
|
4db01244ec | ||
|
|
6021110fb2 | ||
|
|
1216fa940e | ||
|
|
bcf4adf944 | ||
|
|
07ea745f56 | ||
|
|
3e122c84ef | ||
|
|
98a54b4633 | ||
|
|
2db85b1c53 | ||
|
|
91545cf906 | ||
|
|
5a15822ce0 | ||
|
|
eef21b6c34 | ||
|
|
498b3b1226 | ||
|
|
3b77686cee | ||
|
|
bf511f9f8c | ||
|
|
a0eed2cc51 | ||
|
|
e61aa46dad | ||
|
|
d2831ec6f0 | ||
|
|
a7e71624e3 | ||
|
|
bc0017a1b4 | ||
|
|
4cb7b2d494 | ||
|
|
eb7c8a3ad5 | ||
|
|
6e2f90aba8 | ||
|
|
9c59c0e1a2 | ||
|
|
6a9a54cad6 | ||
|
|
e768145961 | ||
|
|
a4e040a7d7 | ||
|
|
e818443841 | ||
|
|
337d0af136 | ||
|
|
4d2667764f | ||
|
|
feb676b66f | ||
|
|
14871c2255 | ||
|
|
48fe52b207 | ||
|
|
509bd30252 | ||
|
|
91955ad1e0 | ||
|
|
b41a4a000f | ||
|
|
d29d1cf63b | ||
|
|
1f8ff7f6d2 | ||
|
|
e251a63cb3 | ||
|
|
eb654c2fe7 | ||
|
|
697b6e0653 | ||
|
|
acd44328d6 | ||
|
|
8b813f645f | ||
|
|
2d354fa294 | ||
|
|
cfb22d3f06 | ||
|
|
1a196c8cf2 | ||
|
|
f847a71747 | ||
|
|
87c0a915b8 | ||
|
|
2958041dc7 | ||
|
|
5d1460e051 | ||
|
|
6a9017bfce | ||
|
|
a1b0db38f5 | ||
|
|
a99546b390 | ||
|
|
1adbf23be4 | ||
|
|
afbb656510 | ||
|
|
1726df1169 | ||
|
|
d69d862034 | ||
|
|
a03cab4a09 | ||
|
|
c90ed14a24 | ||
|
|
e00df5f7a2 | ||
|
|
3c2497f019 | ||
|
|
3fb007a56d | ||
|
|
10285c5eb9 | ||
|
|
15800d7a80 | ||
|
|
4387a9cdd5 | ||
|
|
8714d93d4b | ||
|
|
68256ece2d | ||
|
|
339808d55b | ||
|
|
e7471f44b0 | ||
|
|
d1a47c068e | ||
|
|
43c476d54a | ||
|
|
9f26383de5 | ||
|
|
8f082674d7 | ||
|
|
fca3f24d91 | ||
|
|
38012c62ff | ||
|
|
63149fe281 | ||
|
|
5509f70ad4 | ||
|
|
110cb147d1 | ||
|
|
92e4066977 | ||
|
|
ba5e802174 | ||
|
|
4b2507b155 | ||
|
|
6d2fcf12cd | ||
|
|
f60fc1cd7a | ||
|
|
9fc270fe69 | ||
|
|
a8ff050518 | ||
|
|
a7bab0ebd2 | ||
|
|
8eda0932b0 | ||
|
|
4e563e3385 | ||
|
|
9c05b5f4e0 | ||
|
|
fa73655134 | ||
|
|
81ee27cdea | ||
|
|
fad28eee2c | ||
|
|
c578689356 | ||
|
|
b9e62d1667 | ||
|
|
09146f8cdd | ||
|
|
0fa97595bf | ||
|
|
7ae38b6c43 | ||
|
|
bfbf7a4663 | ||
|
|
cb0ccb9cdb | ||
|
|
ce70780851 | ||
|
|
30b18d3310 | ||
|
|
1d33e2c51b | ||
|
|
fed676f54f | ||
|
|
9bed5e9f83 | ||
|
|
e16a225eb3 | ||
|
|
67ca47afd4 | ||
|
|
9057537ab8 | ||
|
|
247ea9d1bd | ||
|
|
e91c874863 | ||
|
|
40470d8ca5 | ||
|
|
94c0076778 | ||
|
|
b813498e40 | ||
|
|
cc4512fbbb | ||
|
|
ef4cc55c9a | ||
|
|
e3574e1918 | ||
|
|
7b81c77424 | ||
|
|
c9c968c2e9 | ||
|
|
e3f8fef30c | ||
|
|
ceda0635e4 | ||
|
|
bacb14e5f0 | ||
|
|
9e705ff603 | ||
|
|
1a0f1a7b72 | ||
|
|
3201851667 | ||
|
|
75df21932a | ||
|
|
fb96771b56 | ||
|
|
c4b484fb43 | ||
|
|
37fb79fb87 | ||
|
|
f03039d846 | ||
|
|
69476f69b9 | ||
|
|
bb22978574 | ||
|
|
228253c166 | ||
|
|
d26321006b | ||
|
|
377dd52805 | ||
|
|
d246f6b42c | ||
|
|
59d16ebb4b | ||
|
|
948a173f39 | ||
|
|
56857280d3 | ||
|
|
7e804c408f | ||
|
|
5268f62a64 | ||
|
|
7f101431c5 | ||
|
|
a8ac944959 | ||
|
|
e8259a76f0 | ||
|
|
3983b5a5cf | ||
|
|
79b3a76dc1 | ||
|
|
c2bf17b4dd | ||
|
|
be3afbd279 | ||
|
|
18690ceed2 | ||
|
|
0f42a6ed82 | ||
|
|
545299fc62 | ||
|
|
3c1456706a | ||
|
|
a81053e6ff | ||
|
|
391c2fbe5d | ||
|
|
121bdbd614 | ||
|
|
dcfdba0a97 | ||
|
|
a68c690874 | ||
|
|
3a814f3d1f | ||
|
|
209322b499 | ||
|
|
4a64d078f3 | ||
|
|
5f4fa4ce1f | ||
|
|
7e5a08e09d | ||
|
|
8958bf5e08 | ||
|
|
8b67120964 | ||
|
|
79d07ac79a | ||
|
|
757c264e3e | ||
|
|
a72bf19454 | ||
|
|
eda3738d59 | ||
|
|
3b4f27f767 | ||
|
|
4cf0de681a | ||
|
|
2c865ede35 | ||
|
|
46f44f4ea7 | ||
|
|
da46eb4791 | ||
|
|
e21fb58479 | ||
|
|
a703acd1fe | ||
|
|
9a84a6ff6c | ||
|
|
84a84e3f31 | ||
|
|
3f29335fd6 | ||
|
|
141a81d4a3 | ||
|
|
2543676437 | ||
|
|
fa22384f24 | ||
|
|
2a603fa1e4 | ||
|
|
31d142effc | ||
|
|
66d8e90647 | ||
|
|
080784cd9f | ||
|
|
c52ef1993f | ||
|
|
6247ac3edc | ||
|
|
cc5cb3475e | ||
|
|
71742d5ad2 | ||
|
|
1bc0822ce6 | ||
|
|
fcefa4d198 | ||
|
|
1e2ff26e86 | ||
|
|
dd8c2ebec6 | ||
|
|
6f620d92be | ||
|
|
61473bfb77 | ||
|
|
b7172092e8 | ||
|
|
ea014e0d89 | ||
|
|
8fa90e8ecf | ||
|
|
9eb17eca32 | ||
|
|
18b247de4c | ||
|
|
94852ce60e | ||
|
|
59312ebe73 | ||
|
|
738031696b | ||
|
|
a44667d2a9 | ||
|
|
b8fc36033b | ||
|
|
6ef0bd9488 | ||
|
|
f3d9523502 | ||
|
|
2b439094c5 | ||
|
|
6317587a6b | ||
|
|
7fdbd0c808 | ||
|
|
44c2534f46 | ||
|
|
af8f9e9057 | ||
|
|
cb9d9944e3 | ||
|
|
2cfeb14d88 | ||
|
|
d5d93eda11 | ||
|
|
9bbdf889d4 | ||
|
|
96f9be26da | ||
|
|
0c03c188f1 | ||
|
|
64af7b1d8a | ||
|
|
a345bb8c0d | ||
|
|
624733e874 | ||
|
|
fd44906bb7 | ||
|
|
01c9f9be49 | ||
|
|
3d9f189f0e | ||
|
|
a83aa928a0 | ||
|
|
0386e0426b | ||
|
|
10e679bb2f | ||
|
|
6c0ac2e8da | ||
|
|
2a124318b9 | ||
|
|
47a755a585 | ||
|
|
40314aa7e5 | ||
|
|
df9d30340d | ||
|
|
1f5b294bd0 | ||
|
|
012f8bcdf7 | ||
|
|
79cb9b502a | ||
|
|
4e9f063385 | ||
|
|
41f75a3f19 | ||
|
|
e2bffbbaca | ||
|
|
9525a68719 | ||
|
|
a6b45702e8 | ||
|
|
7b11d43466 | ||
|
|
4bd49d0d7a | ||
|
|
54afe35fcc | ||
|
|
77c0474947 | ||
|
|
33ed7c0737 | ||
|
|
bfda9b3e78 | ||
|
|
621eb55ae1 | ||
|
|
7eaf3e7d03 | ||
|
|
68216bf868 | ||
|
|
6fbb867f5f | ||
|
|
94a6f315ca | ||
|
|
4baee436ba | ||
|
|
beee7a76d2 | ||
|
|
887a58d639 | ||
|
|
110b02a213 | ||
|
|
6219491389 | ||
|
|
d7c2232062 | ||
|
|
2eb4afbec4 | ||
|
|
b03509a5f4 | ||
|
|
b8a6feef4a | ||
|
|
4bf86f85b4 | ||
|
|
be580c35bb | ||
|
|
cf27773582 | ||
|
|
6083461822 | ||
|
|
3264857e4a | ||
|
|
f1358dd845 | ||
|
|
8043cca126 | ||
|
|
6dd7464793 | ||
|
|
79c272f0fd | ||
|
|
f959f02d40 | ||
|
|
98421126f2 | ||
|
|
ddf3fb6f63 | ||
|
|
aacab1a90c | ||
|
|
fce7e959e5 | ||
|
|
6b6d32b4a3 | ||
|
|
f0197b685f | ||
|
|
53fa33b0c5 | ||
|
|
2f915b33c7 | ||
|
|
fd8230121c | ||
|
|
825c1b496d | ||
|
|
ccde3d4045 | ||
|
|
f7071967c0 | ||
|
|
657f9f0be1 | ||
|
|
628329e493 | ||
|
|
82e7a0080e | ||
|
|
ff1ed8b57d | ||
|
|
17b06bd4d6 | ||
|
|
5fcb49b08e | ||
|
|
044ef59d81 | ||
|
|
51b191ad2c | ||
|
|
e890a5c2f1 | ||
|
|
591dcf5cf2 | ||
|
|
58d6b2add6 | ||
|
|
bcd542f7ee | ||
|
|
37da4e245a | ||
|
|
9ef7952da5 | ||
|
|
eb8878695a | ||
|
|
c596d82dec | ||
|
|
96dd3fa4ca | ||
|
|
14eeff8f75 | ||
|
|
2cbbd9ca36 | ||
|
|
627eb4f335 | ||
|
|
8dd4135f7c | ||
|
|
6bdd7792eb | ||
|
|
099653f732 | ||
|
|
1d62722d47 | ||
|
|
fb642f7d62 | ||
|
|
cb5b8ee1ee | ||
|
|
b4a8c5dde2 | ||
|
|
53a5e18b20 | ||
|
|
6f00cabe96 | ||
|
|
a24e4a793d | ||
|
|
8ebee9922c | ||
|
|
c3d97b8c16 | ||
|
|
5abe664d65 | ||
|
|
3e2eca4fd0 | ||
|
|
c4ea042eb4 | ||
|
|
fe27fb17fb | ||
|
|
11c5884d4f | ||
|
|
e3ea2d1451 | ||
|
|
3a770306cc | ||
|
|
47ee8b9c13 | ||
|
|
bfd1ea72b3 | ||
|
|
c130d0e2c9 | ||
|
|
4fc7cecf30 | ||
|
|
9570e5c2c1 | ||
|
|
33aa70c22b | ||
|
|
558abfcfa3 | ||
|
|
7fcb9e6292 | ||
|
|
2b5247b9a8 | ||
|
|
fa47e8a3c0 | ||
|
|
4d59d518d1 | ||
|
|
9e2faa7e5b | ||
|
|
37392d8774 | ||
|
|
a16550249b | ||
|
|
d6de917878 | ||
|
|
e751289dfb | ||
|
|
07a319259b | ||
|
|
02ad67fe33 | ||
|
|
655e369f21 | ||
|
|
a9b809a32c | ||
|
|
230e7d6259 | ||
|
|
3d20c05ef7 | ||
|
|
9194f78e56 | ||
|
|
70f747d406 | ||
|
|
712b484bc8 | ||
|
|
0c9b5ddd77 | ||
|
|
3f1abb6906 | ||
|
|
29fc0be121 | ||
|
|
5311eb0da1 | ||
|
|
321f21ad49 | ||
|
|
639f26ed5d | ||
|
|
032dcf40e7 | ||
|
|
9a67497d8c | ||
|
|
6426b5d80e | ||
|
|
83e1f99ccf | ||
|
|
05b8b8a442 | ||
|
|
ed17147281 | ||
|
|
a7a3c9f023 | ||
|
|
4c16b11cb4 | ||
|
|
17f09fc8c1 | ||
|
|
3e0293ac28 | ||
|
|
07fba70a90 | ||
|
|
da0e968975 | ||
|
|
98e4e91a98 | ||
|
|
218e73e324 | ||
|
|
51c3beb614 | ||
|
|
9da47ceb22 | ||
|
|
e9f03d8d29 | ||
|
|
e5e09b49f4 | ||
|
|
dda6f34a07 | ||
|
|
44930532dc | ||
|
|
f10f5f071d | ||
|
|
517e84e5ae | ||
|
|
2101a4ecc7 | ||
|
|
8d2cbf32df | ||
|
|
a23758808d | ||
|
|
91db4eefd0 | ||
|
|
f52220a8ec | ||
|
|
1d23bf4ecb | ||
|
|
3b542434a2 | ||
|
|
9b866a6d17 | ||
|
|
d8f66b14a8 | ||
|
|
6e1eaf8aec | ||
|
|
488bd08f04 | ||
|
|
3991d47166 | ||
|
|
0c73f245ab | ||
|
|
989b27426b | ||
|
|
c10cd8240e | ||
|
|
5cd2244bc2 | ||
|
|
0ec4b4c8a4 | ||
|
|
4ec591fbf2 | ||
|
|
ea3dc3257e | ||
|
|
5b914aa78c | ||
|
|
e36fb8c07a | ||
|
|
1af9d00abd | ||
|
|
e8763b3bbd | ||
|
|
f3a9fd4c82 | ||
|
|
baa0ddd787 | ||
|
|
1d3ea3232d | ||
|
|
d784c540b6 | ||
|
|
429fdb47e6 | ||
|
|
912a92cd1a | ||
|
|
00744c0ce5 | ||
|
|
bc97e383d3 | ||
|
|
b8205b5a09 | ||
|
|
bb7fe9fe37 | ||
|
|
cef7dcac71 | ||
|
|
61e12e2c17 | ||
|
|
65e915fd1d | ||
|
|
1761921670 | ||
|
|
e5bbc797e0 | ||
|
|
f30b1f3f6b | ||
|
|
2c6209277f | ||
|
|
5b3593038d | ||
|
|
67b092253d | ||
|
|
db1059e73b | ||
|
|
e265475c17 | ||
|
|
343345529d | ||
|
|
657fcd595c | ||
|
|
d0d71aa51a | ||
|
|
4618184516 | ||
|
|
3cc54deb9c | ||
|
|
f695238e8a | ||
|
|
fb46fcd80f | ||
|
|
e0c928fbe8 | ||
|
|
780f09c1a2 | ||
|
|
f3c9835759 | ||
|
|
f29649e3a8 | ||
|
|
35400b0c2d | ||
|
|
5d710d9d10 | ||
|
|
a9d10163af | ||
|
|
31e871fe1b | ||
|
|
5148370253 | ||
|
|
581bc03d4e | ||
|
|
0f94f92c37 | ||
|
|
ccec46eddd | ||
|
|
ad58f4e852 | ||
|
|
bb312ff0cf | ||
|
|
342d4a268c | ||
|
|
40282c3447 | ||
|
|
fa328e18a1 | ||
|
|
f55f22d2e8 | ||
|
|
4374f53315 | ||
|
|
c8d7dbd8d6 | ||
|
|
cf6228f525 | ||
|
|
62153d7d36 | ||
|
|
a5e6bd3b62 | ||
|
|
063ef02306 | ||
|
|
25a2b417be | ||
|
|
c5c56ff92f | ||
|
|
cf7d129595 | ||
|
|
226a6e58d5 | ||
|
|
87afe4898e | ||
|
|
d100c934c0 | ||
|
|
ffe5d16094 | ||
|
|
078347b722 | ||
|
|
9b48a008dc | ||
|
|
b2ff7d2c28 | ||
|
|
13bf222b8d | ||
|
|
c2b7b6c231 | ||
|
|
c1b9d94c84 | ||
|
|
1f74e660de | ||
|
|
77aac74590 | ||
|
|
d719a1329c | ||
|
|
c302dfe42d | ||
|
|
41b51f10a9 | ||
|
|
97cd877ce5 | ||
|
|
7007d7a556 | ||
|
|
e6fd95453d | ||
|
|
0329b7b784 | ||
|
|
2f487fda66 | ||
|
|
e82c9c5104 | ||
|
|
c31261789c | ||
|
|
6666992d01 | ||
|
|
ab54881eb0 | ||
|
|
f8545c5141 | ||
|
|
bafb63a665 | ||
|
|
425227509a | ||
|
|
f2a3836877 | ||
|
|
3363592751 | ||
|
|
569242d72f | ||
|
|
3321bb3ccc | ||
|
|
dd4641d618 | ||
|
|
0ce61bc91c | ||
|
|
cb647d95f9 | ||
|
|
7eae1f90f6 | ||
|
|
b8702ae124 | ||
|
|
e5d3722adf | ||
|
|
ff4e853fd3 | ||
|
|
654a41c3b0 | ||
|
|
c6af4791f8 | ||
|
|
85a630cfa9 | ||
|
|
ac1a126756 | ||
|
|
96a25d058b | ||
|
|
6964729cb7 | ||
|
|
54d77598ae | ||
|
|
0bcfc7d352 | ||
|
|
faaaec28e1 | ||
|
|
dde02e6111 | ||
|
|
aadc6b665c | ||
|
|
6730e821b2 | ||
|
|
05ab09c469 | ||
|
|
1e0bc61526 | ||
|
|
6e5af5ef70 | ||
|
|
d312c397e1 | ||
|
|
0dce667019 | ||
|
|
65cd9dc3e5 | ||
|
|
10605d9fb0 | ||
|
|
af9711c2a2 | ||
|
|
9a41b2c0dc | ||
|
|
d805a28c9a | ||
|
|
ffe34120c1 | ||
|
|
47a11ee0b5 | ||
|
|
28a489acbe | ||
|
|
7597caa3a5 | ||
|
|
586d2cc42b | ||
|
|
1fd1f8216d | ||
|
|
0c3f730852 | ||
|
|
b678132176 | ||
|
|
e4be1d6b56 | ||
|
|
5f0fba1807 | ||
|
|
205f78b39c | ||
|
|
5aa8883865 | ||
|
|
1e2c3fc4fc | ||
|
|
b004551fe5 | ||
|
|
704858390d | ||
|
|
c569081340 | ||
|
|
77d413d777 | ||
|
|
43e7ad112f | ||
|
|
fbb8249c0d | ||
|
|
707d9ac274 | ||
|
|
026a77306c | ||
|
|
4087e096f2 | ||
|
|
8827c46c33 | ||
|
|
adde9ff237 | ||
|
|
cfb4f4582b | ||
|
|
8bb637962f | ||
|
|
67a2ca6e31 | ||
|
|
98ad1172b0 | ||
|
|
05fbbac493 | ||
|
|
a4e7ac1df6 | ||
|
|
fb31928e44 | ||
|
|
47bf1d04a1 | ||
|
|
b70f32c6c2 | ||
|
|
21ac1825f3 | ||
|
|
0081622f90 | ||
|
|
d089ed22c7 | ||
|
|
861ae81ff0 | ||
|
|
593640ac19 | ||
|
|
62e0a0338d | ||
|
|
5fd3240fcf | ||
|
|
563dd44957 | ||
|
|
23233a3243 | ||
|
|
1000b706be | ||
|
|
53acfbabf6 | ||
|
|
37bb120d20 | ||
|
|
4fd2b4a014 | ||
|
|
9a376e4223 | ||
|
|
b2d85d70ca | ||
|
|
6aa16ec792 | ||
|
|
e46629d11a | ||
|
|
5bb08e6aa4 | ||
|
|
3698e5a833 | ||
|
|
f84febbf89 | ||
|
|
e029012f73 | ||
|
|
9703840a36 | ||
|
|
c24a29fa65 | ||
|
|
3fcb2b1514 | ||
|
|
ab82841426 | ||
|
|
def8135118 | ||
|
|
335e440cc5 | ||
|
|
7c5bb7f383 | ||
|
|
a9f610fa69 | ||
|
|
801e16c988 | ||
|
|
a570a77cca | ||
|
|
b0f068cc5d | ||
|
|
fff82de933 | ||
|
|
8a4a41fcef | ||
|
|
aa5761954e | ||
|
|
8cd2c4d5bd | ||
|
|
940a28cff4 | ||
|
|
c95feea286 | ||
|
|
1d896467dc | ||
|
|
3a655440b9 | ||
|
|
80bae8bc2a | ||
|
|
f68c67021c | ||
|
|
e37a32c83d | ||
|
|
ec0bde819a | ||
|
|
848f99d3e5 | ||
|
|
34d295c1e0 | ||
|
|
a54ac76688 | ||
|
|
cf02a10050 | ||
|
|
6144473ebe | ||
|
|
174f11604a | ||
|
|
3f057628b7 | ||
|
|
46f1507d44 | ||
|
|
d5b8583d6b | ||
|
|
b1f6fff0a5 | ||
|
|
010ab127e2 | ||
|
|
82c3c33610 | ||
|
|
677b8f5acb | ||
|
|
b8ce02b4f7 | ||
|
|
5014e2f5fd | ||
|
|
87b433e290 | ||
|
|
5581f7a085 | ||
|
|
0b3f619280 | ||
|
|
b901a6ffc7 | ||
|
|
fe81eafe2c | ||
|
|
b0b40c16ff | ||
|
|
4fc95adfb9 | ||
|
|
4fb9882b54 | ||
|
|
29055c575f | ||
|
|
5d96d6673b | ||
|
|
763ff03a7b | ||
|
|
cbc811f6ce | ||
|
|
1d9c77522a | ||
|
|
8f26e1a31f | ||
|
|
ddf18fed9a | ||
|
|
b5a0070023 | ||
|
|
eaf8475f9e | ||
|
|
455234e797 | ||
|
|
53bb23b510 | ||
|
|
d735b6316f | ||
|
|
46737d32f8 | ||
|
|
25d38ae632 | ||
|
|
aa83b4a7a7 | ||
|
|
913ce2dbcb | ||
|
|
cae5e520ac | ||
|
|
772f2ea601 | ||
|
|
28fa03451c | ||
|
|
6984984c22 | ||
|
|
1209c835c7 | ||
|
|
e4ebd5cca1 | ||
|
|
f573110725 | ||
|
|
a8620e133a | ||
|
|
ddd6adbcf7 | ||
|
|
b570aaac48 | ||
|
|
086efe6efe | ||
|
|
56f3c95763 | ||
|
|
8a6a961900 | ||
|
|
6e55968487 | ||
|
|
8d8cddcef6 | ||
|
|
b90d5095f1 | ||
|
|
a4505b1281 | ||
|
|
1d72a8f9c1 | ||
|
|
3d5b6141a5 | ||
|
|
203cd5a9d5 | ||
|
|
696ec65175 | ||
|
|
cbb66a5667 | ||
|
|
53ef35ec80 | ||
|
|
1af3067303 | ||
|
|
d026398bab | ||
|
|
684689a82b | ||
|
|
eeb5f41bad | ||
|
|
7180eaea88 | ||
|
|
7cb204f18a | ||
|
|
0342f609d0 | ||
|
|
59840fa419 | ||
|
|
d390d46ee8 | ||
|
|
37eada9682 | ||
|
|
73a5325a38 | ||
|
|
460eb5434d | ||
|
|
8a21cb9a55 | ||
|
|
d0df52ce35 | ||
|
|
c1ed42fd3a | ||
|
|
c4bb6b8161 | ||
|
|
d480aa11f3 | ||
|
|
d63d5eff85 | ||
|
|
5dae2a4792 | ||
|
|
dcbd7dc219 | ||
|
|
2dcf8b8414 | ||
|
|
438f16094f | ||
|
|
40e0b82fa0 | ||
|
|
b9b0a75fe4 | ||
|
|
b6cc0bc3a7 | ||
|
|
d2f1431269 | ||
|
|
4ecaefbade | ||
|
|
c97c9332eb | ||
|
|
c070e5a9f6 | ||
|
|
8cd3a69803 | ||
|
|
791c9c98dc | ||
|
|
025e979935 | ||
|
|
131471a13f | ||
|
|
7ff63077c3 | ||
|
|
faba0cbd07 | ||
|
|
75f17935f9 | ||
|
|
60842fbbb5 | ||
|
|
d58c27d22d | ||
|
|
65550159bb | ||
|
|
3c7ad81d62 | ||
|
|
6b23c9b3ce | ||
|
|
900e54d740 | ||
|
|
8cc70934da | ||
|
|
f92b0943b5 | ||
|
|
920a383136 | ||
|
|
693e37d2df | ||
|
|
9c6036a103 | ||
|
|
751a4d9111 | ||
|
|
aafd332198 | ||
|
|
337cd0c505 | ||
|
|
b15ce9977a | ||
|
|
e0286aebe3 | ||
|
|
45985f1c04 | ||
|
|
776dd2f8ea | ||
|
|
bdfe4adc98 | ||
|
|
00a0371997 | ||
|
|
ded6b5b081 | ||
|
|
e9678ea899 | ||
|
|
8d69f72e2a | ||
|
|
127b4e11de | ||
|
|
22093bed4d | ||
|
|
ebd53ad679 | ||
|
|
280c604327 | ||
|
|
ad31cdbf85 | ||
|
|
0112ab752b | ||
|
|
66fec80e79 | ||
|
|
fddfaecf5e | ||
|
|
71ae1a2307 | ||
|
|
4d338ebd3d | ||
|
|
dc440f1507 | ||
|
|
5c732f844a | ||
|
|
c4044ba0b1 | ||
|
|
a8159b7bda | ||
|
|
0ab20be667 | ||
|
|
3f048d373f | ||
|
|
8b49a3d264 | ||
|
|
6e51a3f45d | ||
|
|
0d770d1c4d | ||
|
|
b45f021bba | ||
|
|
1a1bce3e8c | ||
|
|
6aeb5d40ab | ||
|
|
31ef2d134e | ||
|
|
85b50e67e1 | ||
|
|
9353f89af0 | ||
|
|
380d69e096 | ||
|
|
02e2f4f500 | ||
|
|
0dbfefa834 | ||
|
|
04ab4bd9f2 | ||
|
|
4955166b85 | ||
|
|
6235c772ac | ||
|
|
e2ec3f7942 | ||
|
|
a816235efb | ||
|
|
1e39ab3c2e | ||
|
|
85aa66c76d | ||
|
|
9a4817faf8 | ||
|
|
7b0c80a0c4 | ||
|
|
6ec8df97e8 | ||
|
|
f82964217e | ||
|
|
cfa5535f6e | ||
|
|
2d846b2c58 | ||
|
|
f40e8037dd | ||
|
|
2b21a75982 | ||
|
|
9ee27308db | ||
|
|
1518223de6 | ||
|
|
3063938a82 | ||
|
|
86449cae52 | ||
|
|
7c580e843f | ||
|
|
c9f0685b40 | ||
|
|
afd0dcf2ff | ||
|
|
68d4df71d8 | ||
|
|
596227659a | ||
|
|
3b0dbadb1e | ||
|
|
8a8bc999d2 | ||
|
|
57c7cca556 | ||
|
|
9e6578a71b | ||
|
|
bf818e3b61 | ||
|
|
9e7f291aaf | ||
|
|
46bab1b97f | ||
|
|
0258d01ee6 | ||
|
|
4999a1a0a8 | ||
|
|
8cb8666456 | ||
|
|
48f3f481db | ||
|
|
e60462e068 | ||
|
|
c4877e3b6a | ||
|
|
afbb1b9a5d | ||
|
|
9516619b92 | ||
|
|
e106a65c1d | ||
|
|
d84c9d4b71 | ||
|
|
0046123e22 | ||
|
|
40736a8334 | ||
|
|
0a256adc94 | ||
|
|
0bddc7965b | ||
|
|
258be3b640 | ||
|
|
4dbfeb87b8 | ||
|
|
fa69287449 | ||
|
|
654ce89541 | ||
|
|
fd32597015 | ||
|
|
84cf07b7a2 | ||
|
|
e4476d0bc6 | ||
|
|
0379f01ce8 | ||
|
|
95e72594ea | ||
|
|
f9ffb1cae5 | ||
|
|
91b6e0a382 | ||
|
|
f5f7a23bb0 | ||
|
|
8be9601963 | ||
|
|
eaad1579e6 | ||
|
|
1d8bf56efd | ||
|
|
73db997e92 | ||
|
|
2c5654d694 | ||
|
|
f490c3a5cd | ||
|
|
2d9158b321 | ||
|
|
48d13762d9 | ||
|
|
bd3f73c2fc | ||
|
|
25c33846be | ||
|
|
124c4ca403 | ||
|
|
ef0f8dd4d0 | ||
|
|
d0eca509d4 | ||
|
|
90663793a2 | ||
|
|
783f654953 | ||
|
|
9cdcce1b5f | ||
|
|
06b483f79d | ||
|
|
a00e137ffc | ||
|
|
239238fe47 | ||
|
|
4cd6e0d10f | ||
|
|
9b29a65c68 | ||
|
|
df4a49a9fb | ||
|
|
bb268310e2 | ||
|
|
7b0908cd87 | ||
|
|
95bc742057 | ||
|
|
e40c890a8e | ||
|
|
357c4fd61f | ||
|
|
d28fea80df | ||
|
|
fb1aeb789a | ||
|
|
1f3693d3a2 | ||
|
|
90760da499 | ||
|
|
7950ba7dc5 | ||
|
|
a7088ee538 | ||
|
|
050cba9563 | ||
|
|
2269617a9f | ||
|
|
bdccfa6e78 | ||
|
|
d97ec3fde2 | ||
|
|
d17472f09e | ||
|
|
f8b7cd2925 | ||
|
|
e9c3ac94c6 | ||
|
|
fa71cddb60 | ||
|
|
228cbc8f87 | ||
|
|
da915208a8 | ||
|
|
694167f78f | ||
|
|
a7697032a4 | ||
|
|
e86c8edd4b | ||
|
|
b1be413dc0 | ||
|
|
fdb50a065b | ||
|
|
1ac59d4894 | ||
|
|
32ccf61baa | ||
|
|
1d04c41ae7 | ||
|
|
b2dcf82ca8 | ||
|
|
57b86034cf | ||
|
|
095e312ab3 | ||
|
|
82a9fb3c39 | ||
|
|
e181329a81 | ||
|
|
cdce817928 | ||
|
|
97b0146ce9 | ||
|
|
0a60492146 | ||
|
|
4ea187cfac | ||
|
|
dcba7c62a2 | ||
|
|
dba99455a7 | ||
|
|
7ebce161e8 | ||
|
|
022aec5720 | ||
|
|
11997c024e | ||
|
|
f787b1b02a | ||
|
|
a03368a3fe | ||
|
|
e26ed8481f | ||
|
|
8e98eed5c8 | ||
|
|
0bff15f964 | ||
|
|
3384c6d666 | ||
|
|
5f1c74aca0 |
+10
-16
@@ -1,24 +1,18 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
.uv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
!aether-hub/dist/aether-hub
|
||||
# Build artifacts
|
||||
build/
|
||||
target/
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
# frontend/dist/ - 注释掉,因为我们需要预构建的dist文件
|
||||
aether-vscodex/web/node_modules/
|
||||
aether-vscodex/web/dist/
|
||||
aether-vscodex/vscode-extension/node_modules/
|
||||
aether-vscodex/vscode-extension/dist/
|
||||
|
||||
# Development
|
||||
.git/
|
||||
@@ -60,4 +54,4 @@ Dockerfile.*
|
||||
|
||||
# Deployment
|
||||
deploy/
|
||||
scripts/
|
||||
scripts/
|
||||
|
||||
+151
-36
@@ -1,19 +1,52 @@
|
||||
# ==================== 必须配置(启动前) ====================
|
||||
# 以下配置项必须在项目启动前设置
|
||||
|
||||
# 数据库配置
|
||||
# 应用端口(默认 8084)
|
||||
APP_PORT=8084
|
||||
|
||||
# 对外访问地址,用于一键安装、CC Switch 导入、支付回调等需要生成公网 URL 的场景。
|
||||
# 生产环境建议显式配置为不带内部端口的公网域名,例如 https://aether.example.com
|
||||
# AETHER_PUBLIC_BASE_URL=https://aether.example.com
|
||||
|
||||
# Docker Compose 镜像(默认正式版 latest;提前测试可改 rc/beta;也可固定具体版本)
|
||||
# 示例:
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:latest
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:rc
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:beta
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
# Rust 日志过滤(默认 aether_gateway=info)
|
||||
# 示例: aether_gateway=debug,sqlx=warn
|
||||
RUST_LOG=aether_gateway=info
|
||||
|
||||
# CORS 配置(跨域带 Cookie 时不要写 *,必须显式列出前端源)
|
||||
# 示例: http://localhost:5173,https://app.example.com
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
# CORS_ALLOW_CREDENTIALS=true
|
||||
# 登录刷新 Cookie 对同源浏览器请求和可信反代自动适配 HTTP/HTTPS。
|
||||
# HTTP 自动使用兼容的 SameSite=Lax(显式 Strict 保留);HTTPS 保留原有 SameSite 配置。
|
||||
# 无法确认访问协议时保留安全默认值;HTTPS 反代请正确传递 X-Forwarded-Proto。
|
||||
# AUTH_REFRESH_COOKIE_SECURE 可显式覆盖自动判断,公网部署仍建议使用 HTTPS。
|
||||
# 如果前后端跨站并依赖登录刷新 Cookie,必须使用 HTTPS,并配合:
|
||||
# AUTH_REFRESH_COOKIE_SAMESITE=None
|
||||
# AUTH_REFRESH_COOKIE_SECURE=true
|
||||
|
||||
# 数据库配置(仅支持 PostgreSQL)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_NAME=aether
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_PASSWORD=
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT密钥(使用 python generate_keys.py 生成)
|
||||
# JWT密钥(使用 ./generate_keys.sh 生成)
|
||||
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
||||
JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
|
||||
@@ -21,45 +54,127 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
|
||||
# S3 备份的独立加密密钥(推荐)。未配置时为兼容旧部署,会回退到 ENCRYPTION_KEY。
|
||||
# 密钥轮换前必须保留旧值,离线恢复工具需要它解密历史备份。
|
||||
# AETHER_BACKUP_ENCRYPTION_KEY=change-this-to-a-dedicated-secure-random-string
|
||||
|
||||
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
|
||||
# 首次启动前必须设置 ADMIN_PASSWORD;install.sh 首次生成配置时会提示输入。
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123456
|
||||
ADMIN_USERNAME=admin123456
|
||||
# ADMIN_PASSWORD=
|
||||
|
||||
# ==================== 可选配置(有默认值) ====================
|
||||
# 以下配置项有合理的默认值,可按需调整
|
||||
|
||||
# 应用端口(默认 8084)
|
||||
# APP_PORT=8084
|
||||
# 可信反向代理 IP/CIDR,只有这些来源发送的 X-Real-IP / X-Forwarded-For 会被采用。
|
||||
# 默认仅信任本机回环代理:127.0.0.0/8,::1/128。
|
||||
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
|
||||
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
|
||||
|
||||
# 生产部署镜像(deploy.sh 会读取)
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:latest
|
||||
# VS Code Codex 云端协同(仅在叠加 aether-vscodex/docker-compose.aether.yml 时需要)
|
||||
# 内部 token 至少 24 字节,建议使用:openssl rand -base64 32
|
||||
# AETHER_VSCODEX_INTERNAL_TOKEN=replace-with-a-long-random-secret
|
||||
# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws
|
||||
# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com
|
||||
|
||||
# Gunicorn Worker 数量(默认 2)
|
||||
# Tunnel 请求统一经 Hub 转发,可安全使用多 worker。
|
||||
# 非 Docker 运行时若使用 ProxyNode tunnel,请确保 aether-hub 可达(默认 ws://127.0.0.1:8085)。
|
||||
# GUNICORN_WORKERS=2
|
||||
# 启动时的数据库准备策略:auto(默认)或 verify-only
|
||||
# AETHER_GATEWAY_DATABASE_MODE=auto
|
||||
|
||||
# Gunicorn Max Requests(默认 4000)
|
||||
# Worker 处理指定数量请求后自动重启,防止内存泄漏
|
||||
# max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%)
|
||||
# MAX_REQUESTS=4000
|
||||
# PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算)
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
|
||||
# 普通 PostgreSQL 连接默认语句超时 30 秒、锁等待超时 3 秒;0 显式关闭。
|
||||
# 这是单条 SQL 的期限,不是整个事务总期限;迁移和历史 backfill 使用独立连接放宽。
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_TIMEOUT_MS=30000
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_LOCK_TIMEOUT_MS=3000
|
||||
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
|
||||
# 所有监听分片共用的入站 TCP 连接上限,包含握手、空闲 keep-alive 和升级后的 WebSocket。
|
||||
# 未设置或 0 时按请求上限 + WebSocket 上限推导,最大 65536;显式配置也受 FD 余量限制。
|
||||
# 已知 FD soft limit 时最多 max(1, (FD - 256) / 2),不是整个进程的 FD/内存保证。
|
||||
# 满额的新连接在 HTTP 解析前关闭,不排队创建任务,也不会返回 HTTP 429/503。
|
||||
# AETHER_GATEWAY_MAX_HTTP_CONNECTIONS=4096
|
||||
# 停机先等待 HTTP 请求,再排空本地用量写入;以下期限单位为毫秒。
|
||||
# 进程管理器的强杀期限应覆盖两阶段之和,再预留至少 10 秒收尾。
|
||||
# AETHER_GATEWAY_HTTP_SHUTDOWN_TIMEOUT_MS=30000
|
||||
# AETHER_GATEWAY_USAGE_SHUTDOWN_TIMEOUT_MS=30000
|
||||
# 每客户端、每 origin 的上游空闲连接缓存;不限制活动请求或流持续时间。
|
||||
# AETHER_GATEWAY_UPSTREAM_POOL_MAX_IDLE_PER_HOST=32
|
||||
# AETHER_GATEWAY_UPSTREAM_POOL_IDLE_TIMEOUT_MS=15000
|
||||
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
|
||||
# 请求体按实际缓冲增长申请额度,解压同时计入输入和输出;额度不足返回 503。
|
||||
# 请求体完整读取总超时默认 120000 毫秒;非零值限制在 1000-600000,显式 0 关闭。
|
||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=120000
|
||||
# 上游流首包后空闲超时默认 300000 毫秒;执行配置 read_ms 优先,显式 0 关闭。
|
||||
# AETHER_GATEWAY_UPSTREAM_STREAM_IDLE_TIMEOUT_MS=300000
|
||||
# 流式响应诊断捕获共享预算默认 128 MiB,包含 provider/client 分配;不足时截断审计副本。
|
||||
# 显式 0 关闭此类捕获;不限制协议解析、终态编码和 usage 队列的总内存。
|
||||
# AETHER_GATEWAY_STREAM_CAPTURE_MEMORY_BUDGET_BYTES=134217728
|
||||
# usage 诊断正文共享预算,按 JSON 堆内存估算,默认 128 MiB。
|
||||
# 覆盖终态队列 seed、Redis 解码事件、数据库写入 DTO 及正文副本;额度随正文释放。
|
||||
# 不足或显式 0 时先保留计费事实再舍弃正文;已有清空/禁用状态保留,其余标为截断。
|
||||
# 不包含原始 Redis 批次、解码临时分配、序列化和压缩结果、协议观察缓冲或进程总内存。
|
||||
# AETHER_USAGE_EVENT_CAPTURE_MEMORY_BUDGET_BYTES=134217728
|
||||
# 新 usage 队列消息的完整 JSON payload 上限,默认 1 MiB;显式 0 非法。
|
||||
# 超限先保留计费事实并舍弃诊断字段;仍超限或无法保留计费语义则拒绝入队,终态尝试受限落库,失败即明确失败。
|
||||
# 不限制存量 Redis 消息、整个读取批次、DLQ 或进程总内存。
|
||||
# usage_runtime_queue_payload_* 降级/拒绝计数包含入队和重试预校验的编码尝试,不代表唯一事件数。
|
||||
# AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES=1048576
|
||||
# usage worker 读取/重领共用的逻辑 payload 预留:全进程默认 128 MiB,单批目标 8 MiB。
|
||||
# 按当前消息上限推导 COUNT,默认最多 8 条;预留覆盖整批处理和确认,额度不足等待。
|
||||
# 当前消息上限不能超过总预留额度;单批目标不足一条时仍读一条。0 或非法值回退默认。
|
||||
# 历史/其他生产者的大消息继续处理并计数,不是 RESP、实际堆内存或 DLQ 的硬上限。
|
||||
# AETHER_USAGE_QUEUE_READ_PAYLOAD_BUDGET_BYTES=134217728
|
||||
# AETHER_USAGE_QUEUE_READ_BATCH_PAYLOAD_BYTES=8388608
|
||||
# DLQ 原文和最坏 JSON 编码独立预留默认 64 MiB,后台编码及写入最多同时 4 个任务。
|
||||
# 入场前一次预留,额度占满或单条超预算立即失败并保留 pending 原消息,后续重领。
|
||||
# 编码失败不阻塞同批其余正常消息;批次仍报告失败,只有成功项会被确认。
|
||||
# JSON 按字符串最多 6 倍转义保守估算;不截原账务字段,不包含 Redis 命令/连接副本或 RSS。
|
||||
# 0/非法值回退默认;bytes 最大约 4 GiB,jobs 最大 128。超大存量可能需调高额度后恢复。
|
||||
# AETHER_USAGE_DLQ_ENCODING_BUDGET_BYTES=67108864
|
||||
# AETHER_USAGE_DLQ_ENCODING_MAX_JOBS=4
|
||||
# 内置 Redis 死信转移要求 Redis 7+ 及 EVAL/TYPE/XPENDING/XADD/XACK/XDEL 权限。
|
||||
# stream 与 DLQ 不能同名;Cluster 还要求两键同 slot,现有默认键未自动迁移。
|
||||
# 单请求解压后 Payload 上限(MiB),默认 256;显式 0 仍受 256 MiB 硬上限保护。
|
||||
# AETHER_MAX_REQUEST_BODY_MB=256
|
||||
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
|
||||
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=64
|
||||
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=64
|
||||
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
|
||||
# Tunnel relay 使用的独立 HMAC 密钥。启用 HTTP tunnel relay 或多网关 owner 转发时必须配置,
|
||||
# 所有网关实例必须使用同一个至少 32 字节的随机值;不要复用 JWT 或数据加密密钥。
|
||||
# AETHER_TUNNEL_RELAY_AUTH_SECRET=
|
||||
# 旧版 /api/internal/gateway/* 控制面默认关闭。确需独立服务调用时,配置至少 32 字节的
|
||||
# 独立 HMAC 密钥;不要复用 JWT、数据加密或 tunnel relay 密钥。多节点必须使用相同值和共享 Redis。
|
||||
# AETHER_INTERNAL_GATEWAY_AUTH_SECRET=
|
||||
# 远程 relay 地址必须使用 HTTPS;HTTP 仅允许 localhost 或回环 IP。
|
||||
# AETHER_TUNNEL_RELAY_BASE_URL=https://gateway-a.example.com
|
||||
# 跨网关 relay 解析到受控私有地址时才显式开启;默认关闭以防止被篡改的 attachment
|
||||
# 记录诱导网关向内网转发 relay 凭据。该开关不放宽普通 provider 的目标地址策略。
|
||||
# AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS=false
|
||||
# 更推荐按 relay 主机名精确放行私网部署(逗号分隔,大小写不敏感);不支持通配符/后缀。
|
||||
# AETHER_TUNNEL_RELAY_PRIVATE_HOST_ALLOWLIST=gateway-a.internal,gateway-b.internal
|
||||
# Bark 自建服务默认仅允许公网 HTTPS。确需明文 HTTP 或内网目标时分别显式开启:
|
||||
# AETHER_BARK_ALLOW_HTTP=false
|
||||
# AETHER_BARK_ALLOW_PRIVATE_TARGETS=false
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
# API_KEY_PREFIX=sk
|
||||
# 普通 Provider 反代(包括 Provider OAuth)不按 DNS 地址过滤上游,兼容任意
|
||||
# Fake-IP 域名及内网 DNS。仅信任管理员配置的上游;没有严格 DNS 过滤开关。
|
||||
# URL 协议、字面 IP、TLS 证书,以及隧道中继和登录 OAuth 的校验仍保留。
|
||||
|
||||
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
|
||||
# LOG_LEVEL=INFO
|
||||
# 可选 Provider OAuth 客户端。Gemini CLI 和 Antigravity 默认使用内置 native-app
|
||||
# 客户端凭据;自定义 client ID 时必须同时配置对应的 client secret。
|
||||
# 显式配置的 client secret 优先于默认值。
|
||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_ID=
|
||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# CORS 配置(允许跨域的源,多个源用逗号分隔)
|
||||
# 示例: http://localhost:3000,https://example.com
|
||||
# 默认: * (允许所有源)
|
||||
# CORS_ORIGINS=*
|
||||
|
||||
# ==================== 计费系统(可选) ====================
|
||||
# Video/Image/Audio 缺失 billing_rule 时是否拒绝请求(默认 false:允许请求但 cost=0 并告警)
|
||||
# BILLING_REQUIRE_RULE=false
|
||||
#
|
||||
# required 维度缺失时是否拒绝请求/标记任务失败(默认 false:cost=0 + 标记 incomplete)
|
||||
# BILLING_STRICT_MODE=false
|
||||
#
|
||||
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
|
||||
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
|
||||
# 内置默认:shared_buffers=1GB, effective_cache_size=3GB, shm_size=512mb,
|
||||
# work_mem=16MB, maintenance_work_mem=256MB。
|
||||
# POSTGRES_SHARED_BUFFERS=8GB
|
||||
# POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
|
||||
# POSTGRES_SHM_SIZE=2gb
|
||||
# POSTGRES_WORK_MEM=16MB
|
||||
# POSTGRES_MAINTENANCE_WORK_MEM=1GB
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
name: Build aether-hub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['hub-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_IMAGE: fawney19/aether-hub
|
||||
DOCKERHUB_IMAGE: fawney19/aether-hub
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
use_cross: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: aether-hub -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
working-directory: aether-hub
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||
cross build --release --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
cd aether-hub/target/${{ matrix.target }}/release
|
||||
chmod +x aether-hub
|
||||
tar czf ../../../../aether-hub-${{ matrix.name }}.tar.gz aether-hub
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aether-hub-${{ matrix.name }}
|
||||
path: aether-hub-*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-hub-* > SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "${{ github.ref_name }}"
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/aether-hub-*
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: aether-hub-linux-*
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare binaries
|
||||
run: |
|
||||
mkdir -p aether-hub/build/linux-amd64 aether-hub/build/linux-arm64
|
||||
tar xzf artifacts/aether-hub-linux-amd64.tar.gz -C aether-hub/build/linux-amd64
|
||||
tar xzf artifacts/aether-hub-linux-arm64.tar.gz -C aether-hub/build/linux-arm64
|
||||
|
||||
- name: Generate CI Dockerfile
|
||||
run: |
|
||||
cat > aether-hub/Dockerfile.ci << 'EOF'
|
||||
FROM debian:bookworm-slim
|
||||
ARG TARGETARCH
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY build/linux-${TARGETARCH}/aether-hub /usr/local/bin/aether-hub
|
||||
EXPOSE 8085
|
||||
ENTRYPOINT ["/usr/local/bin/aether-hub"]
|
||||
CMD ["--bind", "0.0.0.0:8085"]
|
||||
EOF
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=match,pattern=hub-v(.*),group=1
|
||||
type=match,pattern=hub-v(\d+\.\d+),group=1
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./aether-hub
|
||||
file: ./aether-hub/Dockerfile.ci
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -1,227 +0,0 @@
|
||||
name: Build aether-proxy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['proxy-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_IMAGE: fawney19/aether-proxy
|
||||
DOCKERHUB_IMAGE: fawney19/aether-proxy
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
use_cross: false
|
||||
- name: windows-amd64
|
||||
target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use_cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: aether-proxy -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
working-directory: aether-proxy
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||
cross build --release --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd aether-proxy/target/${{ matrix.target }}/release
|
||||
chmod +x aether-proxy
|
||||
tar czf ../../../../aether-proxy-${{ matrix.name }}.tar.gz aether-proxy
|
||||
|
||||
- name: Package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd aether-proxy/target/${{ matrix.target }}/release
|
||||
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aether-proxy-${{ matrix.name }}
|
||||
path: |
|
||||
aether-proxy-*.tar.gz
|
||||
aether-proxy-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-proxy-* > SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "${{ github.ref_name }}"
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/aether-proxy-*
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: aether-proxy-linux-*
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare binaries
|
||||
run: |
|
||||
mkdir -p aether-proxy/build/linux-amd64 aether-proxy/build/linux-arm64
|
||||
tar xzf artifacts/aether-proxy-linux-amd64.tar.gz -C aether-proxy/build/linux-amd64
|
||||
tar xzf artifacts/aether-proxy-linux-arm64.tar.gz -C aether-proxy/build/linux-arm64
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=match,pattern=proxy-v(.*),group=1
|
||||
type=match,pattern=proxy-v(\d+\.\d+),group=1
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./aether-proxy
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
update-readme:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
|
||||
- name: Update README download links
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#proxy-v}"
|
||||
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
|
||||
cd aether-proxy
|
||||
|
||||
TABLE="| Platform | Download |\n|----------|----------|\n"
|
||||
TABLE+="| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](${BASE}/aether-proxy-linux-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](${BASE}/aether-proxy-linux-arm64.tar.gz) |\n"
|
||||
TABLE+="| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](${BASE}/aether-proxy-macos-amd64.tar.gz) |\n"
|
||||
TABLE+="| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](${BASE}/aether-proxy-macos-arm64.tar.gz) |\n"
|
||||
TABLE+="| Windows x86_64 | [aether-proxy-windows-amd64.zip](${BASE}/aether-proxy-windows-amd64.zip) |"
|
||||
|
||||
# Replace content between markers
|
||||
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
|
||||
awk -v table="$TABLE" '
|
||||
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
|
||||
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
|
||||
!skip { print }
|
||||
' README.md > README.tmp && mv README.tmp README.md
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd aether-proxy
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet && exit 0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
git commit -m "chore(proxy): update download links for ${TAG}"
|
||||
git push
|
||||
@@ -0,0 +1,263 @@
|
||||
name: Build aether-tunnel
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['tunnel-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-tunnel-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Ensure tunnel tag matches Cargo version
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
EXPECTED="${TAG#tunnel-v}"
|
||||
ACTUAL="$(cargo metadata --manifest-path apps/aether-tunnel/Cargo.toml --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "aether-tunnel") | .version')"
|
||||
|
||||
echo "tag version: ${EXPECTED}"
|
||||
echo "cargo version: ${ACTUAL}"
|
||||
|
||||
if [ -z "${ACTUAL}" ]; then
|
||||
echo "Could not resolve aether-tunnel package version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${EXPECTED}" != "${ACTUAL}" ]; then
|
||||
echo "tunnel tag ${TAG} does not match apps/aether-tunnel/Cargo.toml version ${ACTUAL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: preflight
|
||||
if: always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped')
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-musl-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-musl-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
- name: windows-amd64
|
||||
target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use_cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Ensure Rust target is installed
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
workspaces: apps/aether-tunnel -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
||||
|
||||
- name: Build
|
||||
working-directory: apps/aether-tunnel
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||
cross build --release --locked --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
chmod +x aether-tunnel
|
||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel
|
||||
|
||||
- name: Package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
7z a ../../../aether-tunnel-${{ matrix.name }}.zip aether-tunnel.exe
|
||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: aether-tunnel-${{ matrix.name }}
|
||||
path: |
|
||||
aether-tunnel-*.tar.gz
|
||||
aether-tunnel-*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-tunnel-* > SHA256SUMS.txt
|
||||
|
||||
- name: Attest tunnel release provenance
|
||||
id: attest-release
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
artifacts/aether-tunnel-*.tar.gz
|
||||
artifacts/aether-tunnel-*.zip
|
||||
artifacts/SHA256SUMS.txt
|
||||
|
||||
- name: Bundle tunnel release provenance
|
||||
env:
|
||||
ATTESTATION_BUNDLE: ${{ steps.attest-release.outputs.bundle-path }}
|
||||
run: install -m 0644 "${ATTESTATION_BUNDLE}" artifacts/AETHER_TUNNEL_RELEASE_PROVENANCE.sigstore.json
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||
|
||||
if [[ -z "${draft_ids}" ]]; then
|
||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r release_id; do
|
||||
[[ -z "${release_id}" ]] && continue
|
||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
name: "${{ github.ref_name }}"
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/aether-tunnel-*
|
||||
artifacts/AETHER_TUNNEL_RELEASE_PROVENANCE.sigstore.json
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
update-readme:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update README download links
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#tunnel-v}"
|
||||
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
|
||||
|
||||
if [ -d apps/aether-tunnel ]; then
|
||||
TUNNEL_DIR="apps/aether-tunnel"
|
||||
else
|
||||
TUNNEL_DIR="aether-tunnel"
|
||||
fi
|
||||
|
||||
cd "$TUNNEL_DIR"
|
||||
|
||||
TABLE="| Platform | Download |\n|----------|----------|\n"
|
||||
TABLE+="| Linux x86_64 (GNU) | [aether-tunnel-linux-amd64.tar.gz](${BASE}/aether-tunnel-linux-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 (GNU) | [aether-tunnel-linux-arm64.tar.gz](${BASE}/aether-tunnel-linux-arm64.tar.gz) |\n"
|
||||
TABLE+="| Linux x86_64 (musl) | [aether-tunnel-linux-musl-amd64.tar.gz](${BASE}/aether-tunnel-linux-musl-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 (musl) | [aether-tunnel-linux-musl-arm64.tar.gz](${BASE}/aether-tunnel-linux-musl-arm64.tar.gz) |\n"
|
||||
TABLE+="| macOS x86_64 | [aether-tunnel-macos-amd64.tar.gz](${BASE}/aether-tunnel-macos-amd64.tar.gz) |\n"
|
||||
TABLE+="| macOS ARM64 | [aether-tunnel-macos-arm64.tar.gz](${BASE}/aether-tunnel-macos-arm64.tar.gz) |\n"
|
||||
TABLE+="| Windows x86_64 | [aether-tunnel-windows-amd64.zip](${BASE}/aether-tunnel-windows-amd64.zip) |"
|
||||
|
||||
# Replace content between markers
|
||||
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
|
||||
awk -v table="$TABLE" '
|
||||
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
|
||||
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
|
||||
!skip { print }
|
||||
' README.md > README.tmp && mv README.tmp README.md
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if [ -d apps/aether-tunnel ]; then
|
||||
TUNNEL_DIR="apps/aether-tunnel"
|
||||
else
|
||||
TUNNEL_DIR="aether-tunnel"
|
||||
fi
|
||||
|
||||
cd "$TUNNEL_DIR"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet && exit 0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
git commit -m "chore(tunnel): update download links for ${TAG}"
|
||||
git push
|
||||
@@ -7,25 +7,59 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
deploy_pages: ${{ steps.classify.outputs.deploy_pages }}
|
||||
steps:
|
||||
- name: Ensure stable Pages release tag
|
||||
id: classify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "deploy_pages=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Manual Pages deployment."
|
||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Pages deploy for non-stable release tag: ${tag}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
build:
|
||||
needs: preflight
|
||||
if: needs.preflight.outputs.deploy_pages == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
|
||||
- name: Build aether-vscodex web
|
||||
working-directory: aether-vscodex/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
@@ -41,10 +75,10 @@ jobs:
|
||||
run: cp frontend/dist/index.html frontend/dist/404.html
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: frontend/dist
|
||||
|
||||
@@ -54,7 +88,10 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
id-token: write
|
||||
pages: write
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_base:
|
||||
description: 'Rebuild base image'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
BASE_IMAGE_NAME: fawney19/aether-base
|
||||
APP_IMAGE_NAME: fawney19/aether
|
||||
GITHUB_REPO: fawney19/Aether
|
||||
# Base image hash inputs:
|
||||
# - Dockerfile.base
|
||||
# - pyproject.toml (dependency fingerprint only; ignores tool/optional deps)
|
||||
# - frontend/package-lock.json
|
||||
|
||||
jobs:
|
||||
check-base-changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
outputs:
|
||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if base image needs rebuild
|
||||
id: check
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.build_base }}" == "true" ]; then
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Calculate current hash of base-related inputs (dependency-only fingerprint)
|
||||
PY_FINGERPRINT=$(python3 - <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import tomllib
|
||||
|
||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||
project = data.get("project") or {}
|
||||
build = data.get("build-system") or {}
|
||||
|
||||
fingerprint = {
|
||||
"requires-python": project.get("requires-python"),
|
||||
"dependencies": sorted(project.get("dependencies") or []),
|
||||
"build-backend": build.get("build-backend"),
|
||||
"build-requires": sorted(build.get("requires") or []),
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
CURRENT_HASH=$(
|
||||
(
|
||||
cat Dockerfile.base
|
||||
printf '%s\n' "$PY_FINGERPRINT"
|
||||
cat frontend/package-lock.json
|
||||
) | sha256sum | cut -d' ' -f1
|
||||
)
|
||||
echo "Current base hash: $CURRENT_HASH"
|
||||
|
||||
# Try to get hash label from remote image config
|
||||
# Pull the image config and extract labels
|
||||
REMOTE_HASH=""
|
||||
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest; then
|
||||
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
||||
else
|
||||
echo "WARN: failed to pull remote base image; forcing base rebuild."
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
||||
# No remote image or no hash label, need to rebuild
|
||||
echo "No remote base image or hash label found, need rebuild"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
|
||||
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Hash matches, no rebuild needed"
|
||||
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-base:
|
||||
needs: check-base-changes
|
||||
if: needs.check-base-changes.outputs.base_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Calculate base files hash
|
||||
id: hash
|
||||
run: |
|
||||
PY_FINGERPRINT=$(python3 - <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import tomllib
|
||||
|
||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||
project = data.get("project") or {}
|
||||
build = data.get("build-system") or {}
|
||||
|
||||
fingerprint = {
|
||||
"requires-python": project.get("requires-python"),
|
||||
"dependencies": sorted(project.get("dependencies") or []),
|
||||
"build-backend": build.get("build-backend"),
|
||||
"build-requires": sorted(build.get("requires") or []),
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
HASH=$(
|
||||
(
|
||||
cat Dockerfile.base
|
||||
printf '%s\n' "$PY_FINGERPRINT"
|
||||
cat frontend/package-lock.json
|
||||
) | sha256sum | cut -d' ' -f1
|
||||
)
|
||||
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
labels: |
|
||||
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=base
|
||||
cache-to: type=gha,mode=max,scope=base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
download-hub:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
hub_tag: ${{ steps.hub-tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Get latest hub release tag
|
||||
id: hub-tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG=$(gh release list --repo "${{ env.GITHUB_REPO }}" --limit 50 --json tagName,isDraft,isPrerelease \
|
||||
--jq '[.[] | select(.tagName | startswith("hub-v")) | select(.isDraft == false and .isPrerelease == false)] | .[0].tagName')
|
||||
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
|
||||
echo "No hub release found"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Hub release tag: $TAG"
|
||||
|
||||
build-app:
|
||||
needs: [check-base-changes, build-base, download-hub]
|
||||
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') && needs.download-hub.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for app image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
|
||||
docker.io/fawney19/aether
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=pre,enable=${{ contains(github.ref, '-') }}
|
||||
type=raw,value=fix,enable=${{ contains(github.ref, '-fix') }}
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
# 从 tag 提取版本号,如 v0.2.5 -> 0.2.5
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
if [ "$VERSION" = "$GITHUB_REF" ]; then
|
||||
# 不是 tag 触发,使用 git describe
|
||||
VERSION=$(git describe --tags --always | sed 's/^v//')
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION"
|
||||
|
||||
- name: Update Dockerfile.app to use registry base image
|
||||
run: |
|
||||
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
|
||||
|
||||
- name: Generate version file
|
||||
run: |
|
||||
# 生成 _version.py 文件
|
||||
cat > src/_version.py << EOF
|
||||
# Auto-generated by CI
|
||||
__version__ = '${{ steps.version.outputs.version }}'
|
||||
__version_tuple__ = tuple(int(x) for x in '${{ steps.version.outputs.version }}'.split('.') if x.isdigit())
|
||||
version = __version__
|
||||
version_tuple = __version_tuple__
|
||||
EOF
|
||||
|
||||
- name: Resolve hub release for build args
|
||||
run: |
|
||||
echo "Hub release tag: ${{ needs.download-hub.outputs.hub_tag }}"
|
||||
|
||||
- name: Build and push app image (amd64)
|
||||
id: build-amd64
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache-filters: builder
|
||||
cache-from: type=gha,scope=app-amd64
|
||||
cache-to: type=gha,mode=min,scope=app-amd64
|
||||
build-args: |
|
||||
HUB_RELEASE_REPO=${{ env.GITHUB_REPO }}
|
||||
HUB_TAG=${{ needs.download-hub.outputs.hub_tag }}
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }},docker.io/fawney19/aether",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Build and push app image (arm64)
|
||||
id: build-arm64
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache-filters: builder
|
||||
cache-from: type=gha,scope=app-arm64
|
||||
cache-to: type=gha,mode=min,scope=app-arm64
|
||||
build-args: |
|
||||
HUB_RELEASE_REPO=${{ env.GITHUB_REPO }}
|
||||
HUB_TAG=${{ needs.download-hub.outputs.hub_tag }}
|
||||
platforms: linux/arm64
|
||||
outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }},docker.io/fawney19/aether",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Create multi-arch manifest and push
|
||||
run: |
|
||||
# Extract digests
|
||||
AMD64_DIGEST="${{ steps.build-amd64.outputs.digest }}"
|
||||
ARM64_DIGEST="${{ steps.build-arm64.outputs.digest }}"
|
||||
echo "amd64 digest: $AMD64_DIGEST"
|
||||
echo "arm64 digest: $ARM64_DIGEST"
|
||||
|
||||
# For each tag, create multi-arch manifest on each registry
|
||||
TAGS=$(echo "${{ steps.meta.outputs.tags }}" | tr '\n' ' ')
|
||||
for FULL_TAG in $TAGS; do
|
||||
# Determine which registry this tag belongs to
|
||||
if [[ "$FULL_TAG" == ghcr.io/* ]]; then
|
||||
REPO="${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}"
|
||||
elif [[ "$FULL_TAG" == docker.io/* ]]; then
|
||||
REPO="docker.io/fawney19/aether"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
echo "Creating manifest for $FULL_TAG"
|
||||
docker buildx imagetools create -t "$FULL_TAG" \
|
||||
"$REPO@$AMD64_DIGEST" \
|
||||
"$REPO@$ARM64_DIGEST"
|
||||
done
|
||||
@@ -0,0 +1,613 @@
|
||||
name: Nightly Release
|
||||
|
||||
on:
|
||||
# 02:17 Asia/Shanghai (18:17 UTC) every day.
|
||||
schedule:
|
||||
- cron: '17 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Checks and builds only need read access. Publishing jobs opt into write access
|
||||
# below so a failed build cannot modify the existing nightly release.
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
# A rolling tag and image are shared by scheduled and manually retried runs.
|
||||
# Keep GitHub Release immutability disabled for this repository: the tag and
|
||||
# assets intentionally move after each successful daily build.
|
||||
concurrency:
|
||||
group: nightly-main
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_PROFILE_DEV_DEBUG: '0'
|
||||
CARGO_PROFILE_TEST_DEBUG: '0'
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: '1'
|
||||
|
||||
jobs:
|
||||
source:
|
||||
name: Resolve main snapshot
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
sha: ${{ steps.snapshot.outputs.sha }}
|
||||
short_sha: ${{ steps.snapshot.outputs.short_sha }}
|
||||
date: ${{ steps.snapshot.outputs.date }}
|
||||
ghcr_image: ${{ steps.snapshot.outputs.ghcr_image }}
|
||||
steps:
|
||||
- name: Require main branch
|
||||
id: snapshot
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Nightly releases must run from refs/heads/main (got ${GITHUB_REF})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sha="${GITHUB_SHA}"
|
||||
# Docker 镜像仓库名必须全小写;GitHub owner 可能保留大写,先统一规范化。
|
||||
repository_owner="${GITHUB_REPOSITORY%%/*}"
|
||||
repository_owner="${repository_owner,,}"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "date=$(date -u +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}"
|
||||
echo "ghcr_image=ghcr.io/${repository_owner}/aether" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building main at ${sha}."
|
||||
|
||||
# Keep the scheduled backend coverage in one place so it cannot drift from PR CI.
|
||||
rust_ci:
|
||||
name: Rust CI
|
||||
needs: source
|
||||
uses: ./.github/workflows/rust-ci.yml
|
||||
|
||||
rust_extended:
|
||||
name: Rust extended checks
|
||||
needs: source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustc -Vv
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: nightly-rust-1.95-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Check all workspace targets
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: cargo check --workspace --all-targets --all-features --locked
|
||||
|
||||
- name: Run workspace doctests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: cargo test --workspace --all-features --doc --locked
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: sccache --show-stats
|
||||
|
||||
frontend:
|
||||
name: Frontend checks and build
|
||||
needs: source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
|
||||
# The frontend prebuild synchronizes the embedded VSCodex UI by running
|
||||
# its build from a separate package. Install that package explicitly so
|
||||
# vue-tsc can resolve vite/client, vitest/globals, and node types in a
|
||||
# clean runner.
|
||||
- name: Install VSCodex web dependencies
|
||||
working-directory: aether-vscodex/web
|
||||
run: npm ci
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npx --no-install eslint .
|
||||
|
||||
- name: Type-check
|
||||
working-directory: frontend
|
||||
run: npm run type-check
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
run: npm run test:run
|
||||
|
||||
- name: Build nightly frontend
|
||||
working-directory: frontend
|
||||
env:
|
||||
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
|
||||
AETHER_VERSION: nightly
|
||||
run: npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: nightly-frontend-dist
|
||||
path: frontend/dist/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
repository_health:
|
||||
name: Repository health checks
|
||||
needs: source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Check generated format coverage matrix
|
||||
run: python3 docs/api/generate_format_field_coverage.py --check
|
||||
|
||||
- name: Test pressure report checker
|
||||
run: node --test tools/pressure/check_gateway_stage_report.test.js
|
||||
|
||||
checks:
|
||||
name: Nightly check gate
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- source
|
||||
- rust_ci
|
||||
- rust_extended
|
||||
- frontend
|
||||
- repository_health
|
||||
steps:
|
||||
- name: Verify check jobs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
failed=0
|
||||
echo "source=${{ needs.source.result }}"
|
||||
echo "rust_ci=${{ needs.rust_ci.result }}"
|
||||
echo "rust_extended=${{ needs.rust_extended.result }}"
|
||||
echo "frontend=${{ needs.frontend.result }}"
|
||||
echo "repository_health=${{ needs.repository_health.result }}"
|
||||
|
||||
for result in \
|
||||
"${{ needs.source.result }}" \
|
||||
"${{ needs.rust_ci.result }}" \
|
||||
"${{ needs.rust_extended.result }}" \
|
||||
"${{ needs.frontend.result }}" \
|
||||
"${{ needs.repository_health.result }}"; do
|
||||
if [[ "${result}" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
echo 'One or more nightly checks failed or were cancelled.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: [source, checks]
|
||||
if: ${{ needs.checks.result == 'success' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: amd64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: arm64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: nightly-release-${{ matrix.target }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
||||
|
||||
- name: Build release binary
|
||||
env:
|
||||
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
|
||||
AETHER_VERSION: nightly
|
||||
AETHER_BUILD_TYPE: release
|
||||
CARGO_TERM_COLOR: always
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
||||
cross build --release --locked -p aether-gateway --target "${{ matrix.target }}"
|
||||
else
|
||||
cargo build --release --locked -p aether-gateway --target "${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: nightly-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: target/${{ matrix.target }}/release/aether-gateway
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Publish nightly GHCR image
|
||||
needs: [source, checks, build]
|
||||
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GHCR_IMAGE: ${{ needs.source.outputs.ghcr_image }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Download Linux binaries and frontend
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
pattern: nightly-*
|
||||
path: artifacts
|
||||
merge-multiple: false
|
||||
|
||||
- name: Prepare Docker build context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/frontend
|
||||
cp artifacts/nightly-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
||||
cp artifacts/nightly-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
||||
chmod 0755 dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
||||
cp -R artifacts/nightly-frontend-dist/. dist/frontend/
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6 # v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push nightly image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.GHCR_IMAGE }}:nightly
|
||||
${{ env.GHCR_IMAGE }}:nightly-${{ needs.source.outputs.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Aether
|
||||
org.opencontainers.image.version=nightly
|
||||
org.opencontainers.image.revision=${{ needs.source.outputs.sha }}
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
package:
|
||||
name: Package nightly archives
|
||||
needs: [source, checks, build]
|
||||
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Download nightly artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
pattern: nightly-*
|
||||
path: artifacts
|
||||
merge-multiple: false
|
||||
|
||||
- name: Build nightly release packages
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ needs.source.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="nightly"
|
||||
|
||||
mkdir -p package release-assets
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-linux-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p "${root}/bin" "${root}/frontend"
|
||||
|
||||
install -m 0755 \
|
||||
"artifacts/nightly-gateway-linux-${arch}/aether-gateway" \
|
||||
"${root}/bin/aether-gateway"
|
||||
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > "${root}/install.sh"
|
||||
chmod 0755 "${root}/install.sh"
|
||||
install -m 0755 update.sh "${root}/update.sh"
|
||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||
install -m 0644 .env.example "${root}/.env.example"
|
||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
||||
install -m 0644 README.md "${root}/README.md"
|
||||
install -m 0644 LICENSE "${root}/LICENSE"
|
||||
|
||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||
done
|
||||
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > release-assets/install.sh
|
||||
chmod 0755 release-assets/install.sh
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||
|
||||
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 2
|
||||
test "$(wc -l < release-assets/SHA256SUMS)" -eq 2
|
||||
(cd release-assets && sha256sum -c SHA256SUMS)
|
||||
for archive in release-assets/*.tar.gz; do
|
||||
tar -tzf "${archive}" >/dev/null
|
||||
done
|
||||
|
||||
- name: Upload nightly package artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: nightly-release-assets
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
github_release:
|
||||
name: Publish nightly GitHub Release
|
||||
needs: [source, checks, docker, package]
|
||||
if: ${{ needs.checks.result == 'success' && needs.docker.result == 'success' && needs.package.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download nightly package artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: nightly-release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Update rolling nightly release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_TAG: nightly
|
||||
SOURCE_SHA: ${{ needs.source.outputs.sha }}
|
||||
SOURCE_SHORT_SHA: ${{ needs.source.outputs.short_sha }}
|
||||
RELEASE_DATE: ${{ needs.source.outputs.date }}
|
||||
GHCR_IMAGE: ${{ needs.source.outputs.ghcr_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_title="Aether Nightly ${RELEASE_DATE} (${SOURCE_SHORT_SHA})"
|
||||
notes_file="${RUNNER_TEMP}/nightly-release-notes.md"
|
||||
cat > "${notes_file}" <<EOF
|
||||
## Aether nightly
|
||||
|
||||
This rolling prerelease was built from [main commit ${SOURCE_SHORT_SHA}](https://github.com/${REPOSITORY}/commit/${SOURCE_SHA}).
|
||||
|
||||
- Source branch: main
|
||||
- Source commit: ${SOURCE_SHA}
|
||||
- Build date (UTC): ${RELEASE_DATE}
|
||||
- Container image: ${GHCR_IMAGE}:nightly
|
||||
- Commit image: ${GHCR_IMAGE}:nightly-${SOURCE_SHA}
|
||||
|
||||
The nightly tag and assets are replaced by the next successful daily build.
|
||||
EOF
|
||||
|
||||
# Create a draft on the first run. Later runs repair the same rolling
|
||||
# release on retry if any upload or metadata update is interrupted.
|
||||
if ! gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--repo "${REPOSITORY}" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--target "${SOURCE_SHA}" \
|
||||
--title "${release_title}" \
|
||||
--notes-file "${notes_file}"
|
||||
fi
|
||||
|
||||
# Upload archives first, then the checksum/installer metadata. This
|
||||
# keeps a failed upload from leaving a checksum that describes files
|
||||
# which have not reached the Release yet.
|
||||
gh release upload "${RELEASE_TAG}" release-assets/*.tar.gz \
|
||||
--repo "${REPOSITORY}" \
|
||||
--clobber
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
release-assets/SHA256SUMS \
|
||||
release-assets/install.sh \
|
||||
--repo "${REPOSITORY}" \
|
||||
--clobber
|
||||
|
||||
published_assets="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
|
||||
while IFS= read -r asset_name; do
|
||||
if [[ "${asset_name}" == aether-nightly-*.tar.gz && ! -f "release-assets/${asset_name}" ]]; then
|
||||
gh release delete-asset "${RELEASE_TAG}" "${asset_name}" \
|
||||
--repo "${REPOSITORY}" \
|
||||
--yes
|
||||
fi
|
||||
done <<<"${published_assets}"
|
||||
|
||||
# target_commitish does not move an existing git tag. Move the ref
|
||||
# only after the complete asset set is available.
|
||||
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh api -X PATCH "repos/${REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \
|
||||
-f "sha=${SOURCE_SHA}" \
|
||||
-F 'force=true' >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/${REPOSITORY}/git/refs" \
|
||||
-f "ref=refs/tags/${RELEASE_TAG}" \
|
||||
-f "sha=${SOURCE_SHA}" >/dev/null
|
||||
fi
|
||||
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--repo "${REPOSITORY}" \
|
||||
--draft=false \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--target "${SOURCE_SHA}" \
|
||||
--title "${release_title}" \
|
||||
--notes-file "${notes_file}"
|
||||
|
||||
expected_assets=(
|
||||
aether-nightly-linux-amd64.tar.gz
|
||||
aether-nightly-linux-arm64.tar.gz
|
||||
SHA256SUMS
|
||||
install.sh
|
||||
)
|
||||
asset_names="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
|
||||
for expected_asset in "${expected_assets[@]}"; do
|
||||
if ! grep -Fxq "${expected_asset}" <<<"${asset_names}"; then
|
||||
echo "Published release is missing asset ${expected_asset}." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
resolved_sha=""
|
||||
for attempt in {1..10}; do
|
||||
resolved_sha="$(gh api "repos/${REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || true)"
|
||||
if [[ "${resolved_sha}" == "${SOURCE_SHA}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${resolved_sha}" != "${SOURCE_SHA}" ]]; then
|
||||
echo "nightly tag resolved to ${resolved_sha}, expected ${SOURCE_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_state="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')"
|
||||
if [[ "${release_state}" != $'false\ttrue' ]]; then
|
||||
echo "nightly release has unexpected state: ${release_state}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Published ${RELEASE_TAG} for ${SOURCE_SHA}."
|
||||
|
||||
summary:
|
||||
name: Nightly summary
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- source
|
||||
- rust_ci
|
||||
- rust_extended
|
||||
- frontend
|
||||
- repository_health
|
||||
- checks
|
||||
- build
|
||||
- docker
|
||||
- package
|
||||
- github_release
|
||||
steps:
|
||||
- name: Verify nightly pipeline
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
failed=0
|
||||
for entry in \
|
||||
"source=${{ needs.source.result }}" \
|
||||
"rust_ci=${{ needs.rust_ci.result }}" \
|
||||
"rust_extended=${{ needs.rust_extended.result }}" \
|
||||
"frontend=${{ needs.frontend.result }}" \
|
||||
"repository_health=${{ needs.repository_health.result }}" \
|
||||
"checks=${{ needs.checks.result }}" \
|
||||
"build=${{ needs.build.result }}" \
|
||||
"docker=${{ needs.docker.result }}" \
|
||||
"package=${{ needs.package.result }}" \
|
||||
"github_release=${{ needs.github_release.result }}"; do
|
||||
echo "${entry}"
|
||||
if [[ "${entry#*=}" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
echo 'Nightly pipeline did not publish a new release.' >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,456 @@
|
||||
name: Release Aether
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-aether-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_IMAGE: fawney19/aether
|
||||
DOCKERHUB_IMAGE: fawney19/aether
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Release preflight
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
publish: ${{ steps.classify.outputs.publish }}
|
||||
version_tag: ${{ steps.classify.outputs.version_tag }}
|
||||
prerelease: ${{ steps.classify.outputs.prerelease }}
|
||||
make_latest: ${{ steps.classify.outputs.make_latest }}
|
||||
steps:
|
||||
- name: Classify release tag
|
||||
id: classify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "publish=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "version_tag=" >> "${GITHUB_OUTPUT}"
|
||||
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "make_latest=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Manual release build; publish jobs will be skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then
|
||||
echo "Unsupported release tag: ${tag}" >&2
|
||||
echo "Expected vX.Y.Z, vX.Y.Z-beta.N, or vX.Y.Z-rc.N." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version_tag=${tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${tag}" == *-* ]]; then
|
||||
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "make_latest=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
echo "publish=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "Manual release build for ${tag}; publish jobs will be skipped."
|
||||
fi
|
||||
|
||||
frontend:
|
||||
name: Build frontend
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
|
||||
- name: Build aether-vscodex web
|
||||
working-directory: aether-vscodex/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install & build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
vscodex:
|
||||
name: Build VS Code Codex extension
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
aether-vscodex/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
aether-vscodex/vscode-extension/package-lock.json
|
||||
|
||||
- name: Install module test dependencies
|
||||
working-directory: aether-vscodex
|
||||
run: npm ci
|
||||
|
||||
- name: Build the embedded Web UI
|
||||
working-directory: aether-vscodex/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: aether-vscodex/vscode-extension
|
||||
run: npm ci
|
||||
|
||||
- name: Check and compile the extension
|
||||
working-directory: aether-vscodex/vscode-extension
|
||||
run: |
|
||||
npm run check
|
||||
npm run build
|
||||
|
||||
- name: Run module tests
|
||||
working-directory: aether-vscodex
|
||||
run: npm test
|
||||
|
||||
- name: Run Web UI tests
|
||||
working-directory: aether-vscodex/web
|
||||
run: npm test
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: aether-vscodex/vscode-extension
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
npx --yes @vscode/vsce package --no-update-package-json --allow-missing-repository
|
||||
source_vsix="codex-remote-collab-${version}.vsix"
|
||||
test -f "${source_vsix}"
|
||||
mv "${source_vsix}" "aether-vscodex-${version}.vsix"
|
||||
unzip -l "aether-vscodex-${version}.vsix" | grep 'extension/node_modules/ws/index.js' >/dev/null
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: aether-vscodex-vsix
|
||||
path: aether-vscodex/vscode-extension/aether-vscodex-*.vsix
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: preflight
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: amd64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: arm64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: release-${{ matrix.target }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
|
||||
AETHER_BUILD_TYPE: release
|
||||
CARGO_TERM_COLOR: always
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
||||
cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: target/${{ matrix.target }}/release/aether-gateway
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker:
|
||||
name: Docker multi-arch
|
||||
needs: [preflight, frontend, build]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare dist layout
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp artifacts/aether-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
||||
cp artifacts/aether-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
||||
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
||||
cp -r artifacts/frontend-dist dist/frontend
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
||||
type=raw,value=latest,enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
||||
type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta.') }}
|
||||
type=raw,value=rc,enable=${{ contains(github.ref_name, '-rc.') }}
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Attest GHCR image provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
|
||||
- name: Attest Docker Hub image provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
|
||||
package:
|
||||
name: Release tarballs
|
||||
needs: [preflight, frontend, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Build release packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
SOURCE_REF="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="snapshot-${GITHUB_SHA::7}"
|
||||
SOURCE_REF="${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
mkdir -p package release-assets
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-linux-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p \
|
||||
"${root}/bin" \
|
||||
"${root}/frontend"
|
||||
|
||||
install -m 0755 "artifacts/aether-gateway-linux-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
||||
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > "${root}/install.sh"
|
||||
chmod 0755 "${root}/install.sh"
|
||||
install -m 0755 update.sh "${root}/update.sh"
|
||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||
install -m 0644 .env.example "${root}/.env.example"
|
||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
||||
install -m 0644 README.md "${root}/README.md"
|
||||
install -m 0644 LICENSE "${root}/LICENSE"
|
||||
|
||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||
done
|
||||
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > release-assets/install.sh
|
||||
chmod +x release-assets/install.sh
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||
|
||||
- name: Attest release package provenance
|
||||
id: attest-release
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
release-assets/*.tar.gz
|
||||
release-assets/install.sh
|
||||
release-assets/SHA256SUMS
|
||||
|
||||
- name: Bundle release package provenance
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
env:
|
||||
ATTESTATION_BUNDLE: ${{ steps.attest-release.outputs.bundle-path }}
|
||||
run: install -m 0644 "${ATTESTATION_BUNDLE}" release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
|
||||
- name: Upload release package artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
github-release:
|
||||
name: GitHub Release assets
|
||||
needs: [preflight, docker, package, vscodex]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download release package artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Download VSIX artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: aether-vscodex-vsix
|
||||
path: release-assets
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||
|
||||
if [[ -z "${draft_ids}" ]]; then
|
||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r release_id; do
|
||||
[[ -z "${release_id}" ]] && continue
|
||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Publish GitHub Release assets
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
||||
make_latest: ${{ needs.preflight.outputs.make_latest }}
|
||||
files: |
|
||||
release-assets/*.tar.gz
|
||||
release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
release-assets/SHA256SUMS
|
||||
release-assets/install.sh
|
||||
release-assets/*.vsix
|
||||
@@ -0,0 +1,612 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- "install.sh"
|
||||
- "deploy.sh"
|
||||
- "update.sh"
|
||||
- "generate_keys.sh"
|
||||
- ".env.example"
|
||||
- "README.md"
|
||||
- "Dockerfile.app"
|
||||
- "docker-compose.yml"
|
||||
- "docker-compose.single-node.yml"
|
||||
- "docker-compose.local.yml"
|
||||
- "docker-compose.release-local.yml"
|
||||
- "tests/compose_database_config_test.py"
|
||||
- "tests/install_*_test.sh"
|
||||
- "tests/deploy_*_test.sh"
|
||||
- "tests/update_*_test.sh"
|
||||
- "tests/release_supply_chain_test.sh"
|
||||
- "tests/tunnel_installer_config_security_test.sh"
|
||||
- ".github/workflows/build-tunnel.yml"
|
||||
- ".github/workflows/deploy-pages.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
- ".github/workflows/nightly.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- "install.sh"
|
||||
- "deploy.sh"
|
||||
- "update.sh"
|
||||
- "generate_keys.sh"
|
||||
- ".env.example"
|
||||
- "README.md"
|
||||
- "Dockerfile.app"
|
||||
- "docker-compose.yml"
|
||||
- "docker-compose.single-node.yml"
|
||||
- "docker-compose.local.yml"
|
||||
- "docker-compose.release-local.yml"
|
||||
- "tests/compose_database_config_test.py"
|
||||
- "tests/install_*_test.sh"
|
||||
- "tests/deploy_*_test.sh"
|
||||
- "tests/update_*_test.sh"
|
||||
- "tests/release_supply_chain_test.sh"
|
||||
- "tests/tunnel_installer_config_security_test.sh"
|
||||
- ".github/workflows/build-tunnel.yml"
|
||||
- ".github/workflows/deploy-pages.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
- ".github/workflows/nightly.yml"
|
||||
|
||||
concurrency:
|
||||
group: rust-ci-${{ github.event_name }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
CARGO_PROFILE_TEST_DEBUG: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
shell_security:
|
||||
name: Shell security fixtures
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Run installer and supply-chain fixtures
|
||||
shell: bash
|
||||
run: |
|
||||
python3 tests/compose_database_config_test.py
|
||||
bash tests/deploy_state_safety_test.sh
|
||||
bash tests/install_archive_safety_test.sh
|
||||
bash tests/install_container_runtime_security_test.sh
|
||||
bash tests/install_current_release_link_test.sh
|
||||
bash tests/install_local_bundle_safety_test.sh
|
||||
bash tests/install_privileged_write_safety_test.sh
|
||||
bash tests/install_source_trust_test.sh
|
||||
bash tests/release_supply_chain_test.sh
|
||||
bash tests/update_compose_safety_test.sh
|
||||
bash tests/tunnel_installer_config_security_test.sh
|
||||
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: rustfmt
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
|
||||
clippy_gateway:
|
||||
name: Clippy (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy -p aether-gateway --lib --bins --examples -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy_data:
|
||||
name: Clippy (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy -p aether-data --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy_rest:
|
||||
name: Clippy (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- clippy_gateway
|
||||
- clippy_data
|
||||
- clippy_rest
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify clippy jobs
|
||||
run: |
|
||||
if [ "${{ needs.clippy_gateway.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy_data.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy_rest.result }}" != "success" ]; then
|
||||
echo "Clippy failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test_gateway:
|
||||
name: Test (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Expose PostgreSQL test binaries
|
||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test lib
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --lib
|
||||
|
||||
- name: Test bins
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --bins
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_data:
|
||||
name: Test (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Expose PostgreSQL test binaries
|
||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
||||
run: cargo nextest run -p aether-data
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check_data_features:
|
||||
name: Check (Data Feature - ${{ matrix.feature }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
feature:
|
||||
- postgres
|
||||
- all-drivers
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Check selected data driver
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_rest:
|
||||
name: Test (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_data_adapters:
|
||||
name: Test (Data Adapter - ${{ matrix.package }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package:
|
||||
- aether-data-postgres
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Test adapter
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run -p ${{ matrix.package }}
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check_integration_scenarios:
|
||||
name: Test (Integration Scenarios)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Expose PostgreSQL test binaries
|
||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test scenario binaries and end-to-end suites
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-integration-tests --bins --tests
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test_gateway
|
||||
- test_data
|
||||
- check_data_features
|
||||
- test_rest
|
||||
- test_data_adapters
|
||||
- check_integration_scenarios
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify test jobs
|
||||
run: |
|
||||
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_data.result }}" != "success" ] || \
|
||||
[ "${{ needs.check_data_features.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_rest.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
|
||||
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
|
||||
echo "Tests failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
data_db_smoke_postgres:
|
||||
name: Data DB Smoke (Postgres)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: aether_test
|
||||
POSTGRES_USER: aether
|
||||
POSTGRES_PASSWORD: aether
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -h 127.0.0.1 -U aether -d aether_test"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Add PostgreSQL server binaries to PATH
|
||||
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run Postgres migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run Postgres provider metadata migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run Postgres API key lifecycle tests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
||||
run: |
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidates_preserve_deleted_api_key_identity --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidate_migration_decouples_legacy_api_key_foreign_key --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_stats_daily_api_key_migration_decouples_legacy_foreign_key --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_expired_api_key_cleanup_preserves_historical_identity --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_api_key_leaderboard_user_filter_preserves_aggregate_history --lib -- --exact --nocapture
|
||||
|
||||
- name: Run Postgres core export smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke:
|
||||
name: Data DB Smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- data_db_smoke_postgres
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify database smoke jobs
|
||||
run: |
|
||||
if [ "${{ needs.data_db_smoke_postgres.result }}" != "success" ]; then
|
||||
echo "Data DB smoke failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check:
|
||||
name: check
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- fmt
|
||||
- clippy
|
||||
- test
|
||||
- data_db_smoke
|
||||
- shell_security
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
run: |
|
||||
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy.result }}" != "success" ] || \
|
||||
[ "${{ needs.test.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke.result }}" != "success" ] || \
|
||||
[ "${{ needs.shell_security.result }}" != "success" ]; then
|
||||
echo "Rust CI failed"
|
||||
exit 1
|
||||
fi
|
||||
+17
-1
@@ -1,11 +1,21 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
*.rsa
|
||||
*_rsa
|
||||
|
||||
# AI Assistant Configuration
|
||||
.codex/
|
||||
.claude/
|
||||
.deepseek/
|
||||
.serena/
|
||||
.gemini*/
|
||||
.plans
|
||||
.playwright-mcp/
|
||||
|
||||
docs/architecture
|
||||
!docs/architecture/architecture-dark.svg
|
||||
!docs/architecture/architecture-light.svg
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
@@ -212,6 +222,10 @@ backups/
|
||||
|
||||
# Runtime lock files
|
||||
.locks/
|
||||
|
||||
# Local Rust/Cargo configuration
|
||||
.cargo/
|
||||
|
||||
# Demo and test files
|
||||
frontend/public/*-demo.html
|
||||
frontend/public/*-measure.html
|
||||
@@ -237,4 +251,6 @@ src/_version.py
|
||||
# Analysis folder (third-party code for reference)
|
||||
analysis/
|
||||
new-api/
|
||||
/aether-proxy/target/
|
||||
apps/aether-tunnel/aether-tunnel.toml
|
||||
# Generated by frontend/scripts/sync-vscodex.mjs.
|
||||
frontend/public/aether-vscodex/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
rust = "latest"
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
Generated
+6620
File diff suppressed because it is too large
Load Diff
+157
@@ -0,0 +1,157 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/aether-tunnel",
|
||||
"crates/aether-ai/formats",
|
||||
"crates/aether-admin",
|
||||
"crates/aether-admission-core",
|
||||
"crates/aether-ai/serving",
|
||||
"crates/aether-pool-core",
|
||||
"crates/aether-provider/core",
|
||||
"crates/aether-provider/pool",
|
||||
"crates/aether-routing-core",
|
||||
"crates/aether-data/contracts",
|
||||
"crates/aether-data/adapters/postgres",
|
||||
"crates/aether-data/query",
|
||||
"crates/aether-data/schema",
|
||||
"crates/aether-dispatch-core",
|
||||
"crates/aether-cache",
|
||||
"crates/aether-billing",
|
||||
"crates/aether-wallet",
|
||||
"crates/aether-crypto",
|
||||
"crates/aether-contracts",
|
||||
"crates/aether-data/runtime",
|
||||
"crates/aether-model-fetch",
|
||||
"crates/aether-oauth",
|
||||
"crates/aether-provider/transport",
|
||||
"crates/aether-scheduler-core",
|
||||
"crates/aether-runtime/state",
|
||||
"crates/aether-task/runtime",
|
||||
"crates/aether-task/core",
|
||||
"crates/aether-gateway/frontdoor",
|
||||
"crates/aether-gateway/control",
|
||||
"crates/aether-gateway/execution",
|
||||
"crates/aether-gateway/workers",
|
||||
"crates/aether-gateway/tunnel",
|
||||
"crates/aether-testing/loadtools",
|
||||
"crates/aether-testing/integration",
|
||||
"crates/aether-usage/core",
|
||||
"crates/aether-testing/support",
|
||||
"crates/aether-usage/runtime",
|
||||
"crates/aether-video-tasks-core",
|
||||
"apps/aether-gateway",
|
||||
"crates/aether-http",
|
||||
"crates/aether-runtime/base",
|
||||
"crates/aether-testing/testkit",
|
||||
]
|
||||
default-members = [
|
||||
"apps/aether-gateway",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "LicenseRef-Aether-NonCommercial"
|
||||
repository = "https://github.com/fawney19/Aether.git"
|
||||
|
||||
[workspace.dependencies]
|
||||
aether-admin = { path = "crates/aether-admin" }
|
||||
aether-admission-core = { path = "crates/aether-admission-core" }
|
||||
aether-ai-formats = { path = "crates/aether-ai/formats" }
|
||||
aether-ai-serving = { path = "crates/aether-ai/serving" }
|
||||
aether-pool-core = { path = "crates/aether-pool-core" }
|
||||
aether-provider-core = { path = "crates/aether-provider/core" }
|
||||
aether-provider-pool = { path = "crates/aether-provider/pool" }
|
||||
aether-routing-core = { path = "crates/aether-routing-core" }
|
||||
aether-data-contracts = { path = "crates/aether-data/contracts" }
|
||||
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
|
||||
aether-data-query = { path = "crates/aether-data/query" }
|
||||
aether-data-schema = { path = "crates/aether-data/schema" }
|
||||
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
||||
aether-cache = { path = "crates/aether-cache" }
|
||||
aether-billing = { path = "crates/aether-billing" }
|
||||
aether-wallet = { path = "crates/aether-wallet" }
|
||||
aether-crypto = { path = "crates/aether-crypto" }
|
||||
aether-contracts = { path = "crates/aether-contracts" }
|
||||
aether-data = { path = "crates/aether-data/runtime" }
|
||||
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
||||
aether-oauth = { path = "crates/aether-oauth" }
|
||||
aether-provider-transport = { path = "crates/aether-provider/transport" }
|
||||
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
||||
aether-runtime-state = { path = "crates/aether-runtime/state" }
|
||||
aether-task-runtime = { path = "crates/aether-task/runtime" }
|
||||
aether-task-core = { path = "crates/aether-task/core" }
|
||||
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
|
||||
aether-gateway-control = { path = "crates/aether-gateway/control" }
|
||||
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
|
||||
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
|
||||
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
|
||||
aether-loadtools = { path = "crates/aether-testing/loadtools" }
|
||||
aether-integration-tests = { path = "crates/aether-testing/integration" }
|
||||
aether-test-support = { path = "crates/aether-testing/support" }
|
||||
aether-usage-core = { path = "crates/aether-usage/core" }
|
||||
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
|
||||
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
||||
aether-gateway = { path = "apps/aether-gateway" }
|
||||
aether-http = { path = "crates/aether-http" }
|
||||
aether-runtime = { path = "crates/aether-runtime/base" }
|
||||
aether-testkit = { path = "crates/aether-testing/testkit" }
|
||||
aes = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
aws-lc-rs = { version = "1.16.2", default-features = false, features = ["alloc", "aws-lc-sys"] }
|
||||
async-stream = "0.3"
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bcrypt = "0.16"
|
||||
brotli = "8"
|
||||
bytes = "1"
|
||||
cbc = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
crypto_box = { version = "0.9", features = ["seal"] }
|
||||
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
hmac = "0.12"
|
||||
http = "1"
|
||||
object_store = { version = "0.14.1", default-features = false, features = ["aws"] }
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
|
||||
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams", "connection-manager"] }
|
||||
regex = "1"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
serde_path_to_error = "0.1"
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.6"
|
||||
tar = "0.4"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] }
|
||||
webpki-roots = "0.26"
|
||||
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
|
||||
wreq-util = "3.0.0-rc.10"
|
||||
url = "2"
|
||||
zstd = "0.13"
|
||||
|
||||
[profile.dev]
|
||||
# Keep file/line information for backtraces while avoiding full debug info
|
||||
# generation on very large crates during local development builds.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test]
|
||||
# The gateway test target pulls in a very large in-crate test tree, so use the
|
||||
# lighter debug format here as well to reduce rustc peak memory.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
codegen-units = 8
|
||||
+42
-209
@@ -1,215 +1,48 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 运行镜像:从 base 提取产物到精简运行时
|
||||
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
||||
# 用于 GitHub Actions CI(官方源)
|
||||
# Aether Gateway runtime image (cross-compilation)
|
||||
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
|
||||
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
||||
#
|
||||
# Build context must contain:
|
||||
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
|
||||
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
|
||||
# dist/frontend/ (npm run build output)
|
||||
|
||||
FROM aether-base:latest AS builder
|
||||
WORKDIR /app
|
||||
# 复制前端源码并构建(CI 通过 no-cache-filters=builder 确保每次重建)
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
# --- layout stage: create /opt/aether directory structure with symlink ---
|
||||
# distroless has no shell, so we use busybox to set up the symlink.
|
||||
FROM busybox:1.37.0-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23 AS layout
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
ARG HUB_RELEASE_REPO=fawney19/Aether
|
||||
ARG HUB_TAG
|
||||
ARG TARGETARCH
|
||||
|
||||
# 运行时依赖(无 gcc/nodejs/npm,使用 BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq5 \
|
||||
curl
|
||||
# 从 base 镜像复制 Python 包
|
||||
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
||||
# 只复制需要的 Python 可执行文件
|
||||
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||
# Hub 预编译二进制(构建时从 GitHub Release 下载)
|
||||
RUN set -eux; \
|
||||
tag="${HUB_TAG:-}"; \
|
||||
if [ -z "$tag" ]; then \
|
||||
tag="$(curl -sL "https://api.github.com/repos/${HUB_RELEASE_REPO}/releases" | python3 -c "import json,sys;print(next((r['tag_name'] for r in json.load(sys.stdin) if r.get('tag_name','').startswith('hub-v') and not r.get('draft') and not r.get('prerelease')),''))")"; \
|
||||
fi; \
|
||||
if [ -z "$tag" ]; then \
|
||||
echo "Failed to resolve hub release tag"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
arch="${TARGETARCH:-}"; \
|
||||
if [ -z "$arch" ]; then \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
fi; \
|
||||
case "$arch" in \
|
||||
amd64|arm64) ;; \
|
||||
x86_64) arch="amd64" ;; \
|
||||
aarch64) arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
|
||||
esac; \
|
||||
echo "Using Hub release tag: $tag"; \
|
||||
url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
|
||||
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
|
||||
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
|
||||
chmod +x /usr/local/bin/aether-hub; \
|
||||
rm -f /tmp/aether-hub.tar.gz
|
||||
# 从 builder 阶段复制前端构建产物
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
RUN chmod -R 755 /usr/share/nginx/html
|
||||
# 复制后端代码
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
COPY gunicorn_conf.py ./
|
||||
# Nginx 配置模板
|
||||
# 策略:白名单后端路由 → 后端代理,其余全部 → 前端 SPA(index.html)
|
||||
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||
RUN printf '%s\n' \
|
||||
'map $http_x_real_ip $real_ip {' \
|
||||
' default $http_x_real_ip;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_x_forwarded_for $forwarded_for {' \
|
||||
' default $http_x_forwarded_for;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
' root /usr/share/nginx/html;' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||
' gzip on;' \
|
||||
' gzip_min_length 256;' \
|
||||
' gzip_comp_level 5;' \
|
||||
' gzip_vary on;' \
|
||||
' gzip_proxied any;' \
|
||||
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||
' gzip_disable "msie6";' \
|
||||
'' \
|
||||
' # 静态资源:长期缓存' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
' try_files $uri =404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 安全:阻止访问源码目录' \
|
||||
' location ~ ^/(src|node_modules)/ {' \
|
||||
' deny all;' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \
|
||||
' location = /api/internal/proxy-tunnel {' \
|
||||
' proxy_pass http://127.0.0.1:8085/proxy;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Upgrade $http_upgrade;' \
|
||||
' proxy_set_header Connection "upgrade";' \
|
||||
' proxy_read_timeout 86400s;' \
|
||||
' proxy_send_timeout 86400s;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 后端 API 路由(白名单)→ 代理到后端' \
|
||||
' location ~ ^/(api|v1|v1beta|upload|health)(/|$) {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||
' proxy_buffering off;' \
|
||||
' proxy_cache off;' \
|
||||
' proxy_request_buffering off;' \
|
||||
' chunked_transfer_encoding on;' \
|
||||
' gzip off;' \
|
||||
' add_header X-Accel-Buffering no;' \
|
||||
' proxy_connect_timeout 60s;' \
|
||||
' proxy_send_timeout 3600s;' \
|
||||
' proxy_read_timeout 3600s;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # API 文档路由 → 代理到后端' \
|
||||
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'}' > /etc/nginx/sites-available/default.template
|
||||
# Supervisor 配置
|
||||
RUN printf '%s\n' \
|
||||
'[supervisord]' \
|
||||
'nodaemon=true' \
|
||||
'logfile=/var/log/supervisor/supervisord.log' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --timeout 120 --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
|
||||
'' \
|
||||
'[program:tunnel-hub]' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' > /etc/supervisor/conf.d/supervisord.conf
|
||||
# 创建目录
|
||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||
# 入口脚本(启动前执行迁移)
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
# 环境变量
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084 \
|
||||
GUNICORN_WORKERS=2 \
|
||||
MAX_REQUESTS=4000
|
||||
EXPOSE 80
|
||||
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
|
||||
|
||||
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
|
||||
COPY dist/frontend/ /opt/aether/releases/image/frontend/
|
||||
|
||||
# Keep the immutable release root-owned while guaranteeing that the runtime
|
||||
# identity can traverse and read every packaged asset.
|
||||
RUN chmod -R u=rwX,go=rX /opt/aether/releases/image \
|
||||
&& chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
||||
|
||||
RUN ln -s /opt/aether/releases/image /opt/aether/current
|
||||
|
||||
# --- final stage: distroless runtime ---
|
||||
FROM gcr.io/distroless/static-debian12@sha256:6447365a6337c3732f412d1b74357b30a633831955b2bc45552b0086be907687
|
||||
|
||||
COPY --from=layout /opt/aether /opt/aether
|
||||
|
||||
WORKDIR /opt/aether
|
||||
|
||||
ENV RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
HOME=/tmp/aether-home \
|
||||
AETHER_UPDATE_STRATEGY=docker \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
+137
-208
@@ -1,232 +1,161 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
|
||||
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||
# 用于本地/国内服务器部署
|
||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
||||
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
|
||||
|
||||
FROM aether-base:latest AS builder
|
||||
ARG RUST_VERSION=1.95.0
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||
|
||||
WORKDIR /app
|
||||
# ==================== 前端构建 ====================
|
||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/aether-vscodex/web
|
||||
COPY aether-vscodex/web/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY aether-vscodex/public /app/aether-vscodex/public
|
||||
COPY aether-vscodex/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 复制前端源码并构建
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM python:3.13-slim
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
WORKDIR /app
|
||||
# 生产级 release 构建:保留 thin LTO,同时用 lld 缩短最终链接阶段。
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
|
||||
RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
|
||||
|
||||
ARG HUB_RELEASE_REPO=fawney19/Aether
|
||||
ARG HUB_TAG
|
||||
ARG TARGETARCH
|
||||
|
||||
# 运行时依赖(使用清华镜像源 + BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq5 \
|
||||
curl
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
git \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
lld \
|
||||
pkg-config \
|
||||
perl
|
||||
|
||||
# 从 base 镜像复制 Python 包
|
||||
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
cargo install cargo-chef --locked
|
||||
|
||||
# 只复制需要的 Python 可执行文件
|
||||
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||
FROM gateway-base AS gateway-planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# Hub 预编译二进制(构建时从 GitHub Release 下载)
|
||||
FROM gateway-base AS gateway-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||
set -eux; \
|
||||
cargo build --release --locked -p aether-gateway --bin aether-gateway --features jemalloc; \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
tag="${HUB_TAG:-}"; \
|
||||
if [ -z "$tag" ]; then \
|
||||
tag="$(curl -sL "https://api.github.com/repos/${HUB_RELEASE_REPO}/releases" | python3 -c "import json,sys;print(next((r['tag_name'] for r in json.load(sys.stdin) if r.get('tag_name','').startswith('hub-v') and not r.get('draft') and not r.get('prerelease')),''))")"; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/app/logs \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/local/bin; \
|
||||
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||
while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
done < /tmp/runtime-libs.txt; \
|
||||
for lib in \
|
||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||
if [ -f "$lib" ]; then \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||
fi; \
|
||||
if [ -z "$tag" ]; then \
|
||||
echo "Failed to resolve hub release tag"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
arch="${TARGETARCH:-}"; \
|
||||
if [ -z "$arch" ]; then \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
fi; \
|
||||
case "$arch" in \
|
||||
amd64|arm64) ;; \
|
||||
x86_64) arch="amd64" ;; \
|
||||
aarch64) arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
|
||||
esac; \
|
||||
echo "Using Hub release tag: $tag"; \
|
||||
url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
|
||||
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
|
||||
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
|
||||
chmod +x /usr/local/bin/aether-hub; \
|
||||
rm -f /tmp/aether-hub.tar.gz
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||
fi
|
||||
|
||||
# 从 builder 阶段复制前端构建产物
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
RUN chmod -R 755 /usr/share/nginx/html
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
# 复制后端代码
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
COPY gunicorn_conf.py ./
|
||||
# 复制 gateway 二进制
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
# Nginx 配置模板
|
||||
# 策略:白名单后端路由 → 后端代理,其余全部 → 前端 SPA(index.html)
|
||||
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||
RUN printf '%s\n' \
|
||||
'map $http_x_real_ip $real_ip {' \
|
||||
' default $http_x_real_ip;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_x_forwarded_for $forwarded_for {' \
|
||||
' default $http_x_forwarded_for;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
' root /usr/share/nginx/html;' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||
' gzip on;' \
|
||||
' gzip_min_length 256;' \
|
||||
' gzip_comp_level 5;' \
|
||||
' gzip_vary on;' \
|
||||
' gzip_proxied any;' \
|
||||
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||
' gzip_disable "msie6";' \
|
||||
'' \
|
||||
' # 静态资源:长期缓存' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
' try_files $uri =404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 安全:阻止访问源码目录' \
|
||||
' location ~ ^/(src|node_modules)/ {' \
|
||||
' deny all;' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \
|
||||
' location = /api/internal/proxy-tunnel {' \
|
||||
' proxy_pass http://127.0.0.1:8085/proxy;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Upgrade $http_upgrade;' \
|
||||
' proxy_set_header Connection "upgrade";' \
|
||||
' proxy_read_timeout 86400s;' \
|
||||
' proxy_send_timeout 86400s;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 后端 API 路由(白名单)→ 代理到后端' \
|
||||
' location ~ ^/(api|v1|v1beta|upload|health)(/|$) {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||
' proxy_buffering off;' \
|
||||
' proxy_cache off;' \
|
||||
' proxy_request_buffering off;' \
|
||||
' chunked_transfer_encoding on;' \
|
||||
' gzip off;' \
|
||||
' add_header X-Accel-Buffering no;' \
|
||||
' proxy_connect_timeout 60s;' \
|
||||
' proxy_send_timeout 3600s;' \
|
||||
' proxy_read_timeout 3600s;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # API 文档路由 → 代理到后端' \
|
||||
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' }' \
|
||||
'' \
|
||||
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'}' > /etc/nginx/sites-available/default.template
|
||||
# 复制前端构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
|
||||
WORKDIR /app
|
||||
|
||||
# Supervisor 配置
|
||||
RUN printf '%s\n' \
|
||||
'[supervisord]' \
|
||||
'nodaemon=true' \
|
||||
'logfile=/var/log/supervisor/supervisord.log' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
|
||||
'' \
|
||||
'[program:tunnel-hub]' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' > /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# 创建目录
|
||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||
|
||||
# 入口脚本(启动前执行迁移)
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
|
||||
|
||||
# 环境变量
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084 \
|
||||
GUNICORN_WORKERS=2 \
|
||||
MAX_REQUESTS=4000
|
||||
RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_UPDATE_STRATEGY=manual \
|
||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||
# Aether 本地发布版联调镜像
|
||||
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
|
||||
|
||||
ARG RUST_VERSION=1.95.0
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||
|
||||
# ==================== 前端构建 ====================
|
||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/aether-vscodex/web
|
||||
COPY aether-vscodex/web/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY aether-vscodex/public /app/aether-vscodex/public
|
||||
COPY aether-vscodex/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
git \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
perl
|
||||
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
cargo install cargo-chef --locked
|
||||
|
||||
FROM gateway-base AS gateway-planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM gateway-base AS gateway-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ARG AETHER_BUILD_TYPE=release
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
|
||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||
cargo build --release --locked -p aether-gateway --features jemalloc && \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/lib \
|
||||
/runtime-root/opt/aether/logs \
|
||||
/runtime-root/opt/aether/releases/image/bin \
|
||||
/runtime-root/opt/aether/releases/image/frontend; \
|
||||
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
|
||||
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||
while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
done < /tmp/runtime-libs.txt; \
|
||||
for lib in \
|
||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||
if [ -f "$lib" ]; then \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||
fi; \
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||
fi
|
||||
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_BASE_DIR=/opt/aether \
|
||||
AETHER_UPDATE_STRATEGY=self \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
@@ -1,28 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 构建镜像:编译环境 + 预编译的依赖
|
||||
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 构建工具(使用 BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm
|
||||
|
||||
# Python 依赖(使用 BuildKit 缓存加速)
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
mkdir -p src && touch src/__init__.py && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install .
|
||||
|
||||
# 前端依赖(只安装,不构建,使用 BuildKit 缓存加速)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
cd frontend && npm ci
|
||||
@@ -1,31 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
|
||||
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 构建工具(使用清华镜像源 + BuildKit 缓存加速)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm
|
||||
|
||||
# pip 镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Python 依赖(使用 BuildKit 缓存加速)
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
mkdir -p src && touch src/__init__.py && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install .
|
||||
|
||||
# 前端依赖(只安装,不构建,使用淘宝镜像源 + BuildKit 缓存加速)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||
@@ -0,0 +1,582 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
DEV_RUST_LOG := info,executor::candidate_loop=debug,stream::execution=debug
|
||||
ifeq ($(origin RUST_LOG), command line)
|
||||
DEV_RUST_LOG := $(RUST_LOG)
|
||||
endif
|
||||
export DEV_RUST_LOG
|
||||
|
||||
.PHONY: dev dev-backend dev-frontend db-status db-prepare migration backfill
|
||||
|
||||
define DEV_BACKEND_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
if [[ -n "$${ADMIN_EMAIL:-}" || -n "$${ADMIN_USERNAME:-}" || -n "$${ADMIN_PASSWORD:-}" ]]; then
|
||||
if [[ -z "$${ADMIN_USERNAME:-}" || -z "$${ADMIN_PASSWORD:-}" ]]; then
|
||||
echo "=> 管理员自举配置不完整,请在 .env 中设置 ADMIN_USERNAME 和 ADMIN_PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
dev_uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
||||
}
|
||||
|
||||
dev_uses_redis_runtime() {
|
||||
local backend
|
||||
backend="$$(lowercase "$${AETHER_RUNTIME_BACKEND:-}")"
|
||||
|
||||
if [[ "$${backend}" == "memory" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$${backend}" == "redis" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
print_dev_infra_hint() {
|
||||
echo "=> 本地开发依赖未就绪。"
|
||||
echo "=> 可手动启动 Postgres / Redis:"
|
||||
echo "=> docker compose up -d postgres redis"
|
||||
}
|
||||
|
||||
check_postgres_ready() {
|
||||
local host="$$1"
|
||||
local port="$$2"
|
||||
|
||||
if command -v pg_isready >/dev/null 2>&1; then
|
||||
pg_isready -h "$${host}" -p "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
check_redis_ready() {
|
||||
local host="$$1"
|
||||
local port="$$2"
|
||||
local password="$$3"
|
||||
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
REDISCLI_AUTH="$${password}" redis-cli -h "$${host}" -p "$${port}" ping >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
is_local_host() {
|
||||
case "$$1" in
|
||||
localhost|127.0.0.1|::1)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_dev_infra() {
|
||||
local postgres_host="$${DB_HOST:-localhost}"
|
||||
local postgres_port="$${DB_PORT:-5432}"
|
||||
local redis_host="$${REDIS_HOST:-localhost}"
|
||||
local redis_port="$${REDIS_PORT:-6379}"
|
||||
local redis_password="$${REDIS_PASSWORD:-}"
|
||||
local need_postgres=false
|
||||
local need_redis=false
|
||||
local services=()
|
||||
|
||||
if dev_uses_postgres_database; then
|
||||
if ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
if is_local_host "$${postgres_host}"; then
|
||||
need_postgres=true
|
||||
services+=(postgres)
|
||||
else
|
||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if dev_uses_redis_runtime; then
|
||||
if ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
if is_local_host "$${redis_host}"; then
|
||||
need_redis=true
|
||||
services+=(redis)
|
||||
else
|
||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$${#services[@]}" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "=> 未找到 docker,无法自动启动本地开发依赖。"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=> 本地开发依赖未就绪,正在启动: docker compose up -d $${services[*]}"
|
||||
if ! docker compose up -d "$${services[@]}"; then
|
||||
echo "=> docker compose 启动本地开发依赖失败。"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
|
||||
for _ in {1..100}; do
|
||||
local ready=true
|
||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
ready=false
|
||||
fi
|
||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
ready=false
|
||||
fi
|
||||
if [ "$${ready}" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
||||
fi
|
||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
||||
fi
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
}
|
||||
|
||||
print_startup_failure_hint() {
|
||||
local log_file="$$1"
|
||||
|
||||
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
|
||||
if grep -Eq "database schema is behind" "$${log_file}"; then
|
||||
echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Eq "database backfills are behind" "$${log_file}"; then
|
||||
echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Eq "bootstrap admin env is partially configured.*ADMIN_PASSWORD" "$${log_file}"; then
|
||||
echo "=> 首次启动需要管理员密码,请在 .env 中设置 ADMIN_PASSWORD"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=> 未识别到明确的修复动作,请根据上面的日志继续排查。"
|
||||
}
|
||||
|
||||
wait_for_startup() {
|
||||
local pid="$$1"
|
||||
local timeout_seconds="$$2"
|
||||
local service_name="$$3"
|
||||
shift 3
|
||||
|
||||
STARTUP_WAIT_EARLY_EXIT=false
|
||||
|
||||
local attempts=$$((timeout_seconds * 10))
|
||||
if [ "$${attempts}" -lt 1 ]; then
|
||||
attempts=1
|
||||
fi
|
||||
|
||||
for ((i = 0; i < attempts; i++)); do
|
||||
if "$$@" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
||||
STARTUP_WAIT_EARLY_EXIT=true
|
||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if "$$@" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
||||
STARTUP_WAIT_EARLY_EXIT=true
|
||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=> $${service_name} 在 $${timeout_seconds}s 内未通过启动检查。"
|
||||
echo "=> 如果这是冷编译或存在并发 cargo 构建,可调大启动超时后重试。"
|
||||
return 1
|
||||
}
|
||||
|
||||
create_gateway_log_file() {
|
||||
local tmp_root="$${TMPDIR:-/tmp}"
|
||||
tmp_root="$${tmp_root%/}"
|
||||
|
||||
GATEWAY_LOG_DIR="$$(mktemp -d "$${tmp_root}/aether-dev-startup.XXXXXX")"
|
||||
GATEWAY_LOG_FILE="$${GATEWAY_LOG_DIR}/gateway.log"
|
||||
: > "$${GATEWAY_LOG_FILE}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status="$${1:-0}"
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "$${GATEWAY_PID:-}" ]; then
|
||||
echo ""
|
||||
echo "=> 停止 aether-gateway..."
|
||||
kill "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
||||
wait "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -n "$${GATEWAY_LOG_FILE:-}" ] && [ -f "$${GATEWAY_LOG_FILE}" ]; then
|
||||
rm -f "$${GATEWAY_LOG_FILE}"
|
||||
fi
|
||||
|
||||
if [ -n "$${GATEWAY_LOG_DIR:-}" ] && [ -d "$${GATEWAY_LOG_DIR}" ]; then
|
||||
rmdir "$${GATEWAY_LOG_DIR}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exit "$${status}"
|
||||
}
|
||||
|
||||
trap 'cleanup 130' INT
|
||||
trap 'cleanup 143' TERM
|
||||
trap 'cleanup $$?' EXIT
|
||||
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
export RUST_LOG="$${DEV_RUST_LOG}"
|
||||
RUST_SERVICE_STARTUP_TIMEOUT_SECONDS="$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-180}"
|
||||
GATEWAY_STARTUP_TIMEOUT_SECONDS="$${GATEWAY_STARTUP_TIMEOUT_SECONDS:-$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}}"
|
||||
export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE="$${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative}"
|
||||
|
||||
if dev_uses_postgres_database; then
|
||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if dev_uses_redis_runtime; then
|
||||
export REDIS_URL="redis://:$${REDIS_PASSWORD:-}@$${REDIS_HOST:-localhost}:$${REDIS_PORT:-6379}/0"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_REDIS_URL"; then
|
||||
export AETHER_GATEWAY_DATA_REDIS_URL="$${REDIS_URL}"
|
||||
fi
|
||||
else
|
||||
unset REDIS_URL
|
||||
unset AETHER_GATEWAY_DATA_REDIS_URL
|
||||
fi
|
||||
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
||||
fi
|
||||
|
||||
export DB_POOL_SIZE="$${DB_POOL_SIZE:-5}"
|
||||
export DB_MAX_OVERFLOW="$${DB_MAX_OVERFLOW:-5}"
|
||||
export HTTP_MAX_CONNECTIONS="$${HTTP_MAX_CONNECTIONS:-20}"
|
||||
export HTTP_KEEPALIVE_CONNECTIONS="$${HTTP_KEEPALIVE_CONNECTIONS:-5}"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "=> 未找到 cargo,无法启动 aether-gateway。请先安装 Rust toolchain。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "=> 未找到 curl,无法检查 aether-gateway 健康状态。请先安装 curl。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$${RUSTC_WRAPPER:-}" ] && command -v sccache >/dev/null 2>&1; then
|
||||
export RUSTC_WRAPPER="$$(command -v sccache)"
|
||||
echo "=> 启用 Rust 编译缓存: $${RUSTC_WRAPPER}"
|
||||
fi
|
||||
|
||||
if ! ensure_dev_infra; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=> 编译 aether-gateway..."
|
||||
cargo build -p aether-gateway --bin aether-gateway
|
||||
|
||||
GATEWAY_PID=""
|
||||
GATEWAY_LOG_DIR=""
|
||||
GATEWAY_LOG_FILE=""
|
||||
STARTUP_WAIT_EARLY_EXIT=false
|
||||
create_gateway_log_file
|
||||
|
||||
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
|
||||
echo "=> 日志过滤: $${RUST_LOG}"
|
||||
echo "=> 执行命令: target/debug/aether-gateway --app-port $${APP_PORT}"
|
||||
target/debug/aether-gateway --app-port "$${APP_PORT}" > >(
|
||||
tee -a "$${GATEWAY_LOG_FILE}"
|
||||
) 2>&1 &
|
||||
GATEWAY_PID=$$!
|
||||
|
||||
if ! wait_for_startup "$${GATEWAY_PID}" "$${GATEWAY_STARTUP_TIMEOUT_SECONDS}" "aether-gateway" curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health"; then
|
||||
if [ "$${STARTUP_WAIT_EARLY_EXIT}" = "true" ]; then
|
||||
GATEWAY_PID=""
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if wait "$${GATEWAY_PID}"; then
|
||||
gateway_exit_code=0
|
||||
else
|
||||
gateway_exit_code=$$?
|
||||
fi
|
||||
|
||||
GATEWAY_PID=""
|
||||
|
||||
if [ "$${gateway_exit_code}" -ne 130 ] && [ "$${gateway_exit_code}" -ne 143 ]; then
|
||||
echo "=> aether-gateway 运行失败并已退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
fi
|
||||
|
||||
exit "$${gateway_exit_code}"
|
||||
endef
|
||||
export DEV_BACKEND_SCRIPT
|
||||
|
||||
define DEV_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
backend_pid=""
|
||||
frontend_pid=""
|
||||
|
||||
cleanup() {
|
||||
local status="$${1:-0}"
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "$${backend_pid}" ] || [ -n "$${frontend_pid}" ]; then
|
||||
echo ""
|
||||
echo "=> 停止本地开发服务..."
|
||||
if [ -n "$${backend_pid}" ]; then
|
||||
kill "$${backend_pid}" >/dev/null 2>&1 || true
|
||||
wait "$${backend_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "$${frontend_pid}" ]; then
|
||||
kill "$${frontend_pid}" >/dev/null 2>&1 || true
|
||||
wait "$${frontend_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$${status}"
|
||||
}
|
||||
|
||||
wait_for_backend_ready() {
|
||||
while :; do
|
||||
if curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${backend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 后端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 后端进程已退出"
|
||||
fi
|
||||
backend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
trap 'cleanup 130' INT
|
||||
trap 'cleanup 143' TERM
|
||||
trap 'cleanup $$?' EXIT
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
|
||||
echo "=> 启动后端: 先编译 aether-gateway,再运行 target/debug/aether-gateway --app-port $${APP_PORT:-8084}"
|
||||
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
|
||||
backend_pid=$$!
|
||||
|
||||
echo "=> 等待后端健康检查: http://127.0.0.1:$${APP_PORT}/_gateway/health"
|
||||
wait_for_backend_ready
|
||||
|
||||
echo "=> 启动前端: cd frontend && npm run dev"
|
||||
( cd frontend && exec npm run dev ) &
|
||||
frontend_pid=$$!
|
||||
|
||||
while :; do
|
||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${backend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 后端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 后端进程已退出"
|
||||
fi
|
||||
backend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${frontend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${frontend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 前端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 前端进程已退出"
|
||||
fi
|
||||
frontend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
endef
|
||||
export DEV_SCRIPT
|
||||
|
||||
define DB_TASK_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$${DB_TASK_COMMAND:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
|
||||
echo "=> 内部错误: DB_TASK_COMMAND / DB_TASK_LABEL 未设置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -a db_task_args <<< "$${DB_TASK_COMMAND}"
|
||||
if [ "$${#db_task_args[@]}" -eq 0 ]; then
|
||||
echo "=> 内部错误: DB_TASK_COMMAND 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
||||
}
|
||||
|
||||
if uses_postgres_database; then
|
||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "=> 未找到 cargo,无法执行 $${DB_TASK_LABEL}。请先安装 Rust toolchain。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway --bin aether-gateway -- $${db_task_args[*]}"
|
||||
exec cargo run -p aether-gateway --bin aether-gateway -- "$${db_task_args[@]}"
|
||||
endef
|
||||
export DB_TASK_SCRIPT
|
||||
|
||||
dev:
|
||||
@$(SHELL) -euo pipefail -c "$$DEV_SCRIPT"
|
||||
|
||||
dev-backend:
|
||||
@$(SHELL) -euo pipefail -c "$$DEV_BACKEND_SCRIPT"
|
||||
|
||||
dev-frontend:
|
||||
@cd frontend && npm run dev
|
||||
|
||||
db-status:
|
||||
@DB_TASK_COMMAND="db status" DB_TASK_LABEL="数据库状态检查" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
db-prepare:
|
||||
@DB_TASK_COMMAND="db prepare" DB_TASK_LABEL="数据库准备" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
migration:
|
||||
@DB_TASK_COMMAND="--migrate" DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
backfill:
|
||||
@DB_TASK_COMMAND="--apply-backfills" DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
@@ -11,6 +11,7 @@
|
||||
<p align="center">
|
||||
<a href="#简介">简介</a> •
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#api-文档">API 文档</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
<a href="#qa">Q&A</a>
|
||||
</p>
|
||||
@@ -34,7 +35,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
||||
|
||||
## 部署
|
||||
|
||||
### Docker Compose(预构建镜像,已废弃)
|
||||
### Docker Compose(推荐:预构建镜像)
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
@@ -43,128 +44,133 @@ cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
# .env 包含数据库、JWT 和数据加密密钥,先限制为仅当前用户可读写
|
||||
chmod 600 .env
|
||||
# 生成 JWT / 加密 / Postgres / Redis 独立随机密钥,并填入 .env
|
||||
./generate_keys.sh
|
||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||
|
||||
# 3. 该模式已废弃,不再作为推荐部署方式
|
||||
# 现在统一使用下面的“本地构建部署”方式
|
||||
|
||||
# 4. 升级前备份
|
||||
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
# 3. Docker 部署 / 更新(PostgreSQL + Redis)
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### Docker Compose(推荐:本地构建镜像)
|
||||
### 一键安装(PostgreSQL + Redis)
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||
./deploy.sh
|
||||
|
||||
# 可选:固定 Hub release 版本
|
||||
./deploy.sh --hub-tag hub-v0.1.0
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
正式版和 Nightly 自动构建仅提供 Linux `amd64` / `arm64` 二进制包,Docker 镜像同样支持这两种架构。macOS 用户可使用 Docker 或自行从源码构建;安装脚本保留对历史 macOS 制品的兼容。独立 Aether Tunnel 的多平台发行不受此调整影响。
|
||||
|
||||
原生 Linux systemd 安装需先准备 PostgreSQL,将连接串通过 `DATABASE_URL` 传给安装进程,并选择 `--mode single-node`;不再自动创建本地数据库文件。
|
||||
|
||||
### Nightly(每日 main 构建)
|
||||
|
||||
Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。
|
||||
|
||||
安装最新 nightly(PostgreSQL + Redis):
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
./dev.sh
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose --channel nightly
|
||||
```
|
||||
|
||||
## Aether Proxy
|
||||
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制部署请沿用已有 PostgreSQL 环境配置,并使用 `--mode single-node --channel nightly` 重新运行安装脚本升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
|
||||
|
||||
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
|
||||
## 本地开发
|
||||
|
||||
依赖 Docker、Rust toolchain、Node.js 和 make。
|
||||
首次启动前需要在 `.env` 中设置 `ADMIN_PASSWORD`,用于创建本地管理员。
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
|
||||
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
|
||||
`make dev` 会先完成后端编译,再开始计算服务健康检查超时。数据库 schema 和必要的派生数据准备也会在启动时自动完成;通常不需要手动区分 migration 与 backfill。升级不会主动重写或清除已有业务历史记录,新写入会直接遵循当前的数据持久化策略。排查或部署前预执行时可使用:
|
||||
|
||||
```bash
|
||||
make db-status
|
||||
make db-prepare
|
||||
```
|
||||
|
||||
## Codex 远程协同
|
||||
|
||||
`aether-vscodex/` 是独立的 VS Code Codex 协同模块:同步模式跟随 VS Code 官方 Codex 面板当前会话且不另起进程;异步模式使用独立 app-server,让浏览器自行列出、恢复、新建和切换会话。两种模式都能从本机 URL 或 Aether 云端查看输出、发送消息和处理授权,模块内的 Vue 前端提供中英文界面。
|
||||
|
||||
安装、云端配对和安全边界请参阅 [`aether-vscodex/README.md`](aether-vscodex/README.md)。
|
||||
|
||||
## Aether Tunnel (可选)
|
||||
|
||||
Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
- Docker Compose 部署或下载预编译二进制直接运行
|
||||
- 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务
|
||||
- 详细文档见 [aether-proxy/README.md](aether-proxy/README.md)
|
||||
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `tunnel-v*` 制品并向现有 `aether-tunnel.toml` 追加 `[[servers]]`
|
||||
- 通过 `aether-tunnel setup` 完成交互式配置,自动注册为系统服务
|
||||
- 详细文档见 [apps/aether-tunnel/README.md](apps/aether-tunnel/README.md)
|
||||
|
||||
## API 文档
|
||||
|
||||
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
|
||||
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
|
||||
- Responses WebSocket mode: [protocol and Aether behavior](docs/WebSocket-Mode.md)
|
||||
- WebSocket probes: [Codex](docs/operations/codex-responses-websocket-probe.md) · [OpenAI Responses](docs/operations/openai-responses-websocket-probe.md)
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 必需配置
|
||||
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
|
||||
- `DATABASE_URL`:PostgreSQL 连接串,例如 `postgresql://USER:PASSWORD@HOST:5432/aether`
|
||||
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时 PostgreSQL 按每核 `4` 条自动推导,总池范围为 `32-100`。该预算按进程计算,多实例部署应按数据库连接上限显式分配
|
||||
- `AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_TIMEOUT_MS` / `AETHER_GATEWAY_DATA_POSTGRES_LOCK_TIMEOUT_MS`:普通数据库连接的单条 SQL / 锁等待期限,默认 `30000` / `3000` 毫秒,显式 `0` 关闭;不是整个事务总期限。迁移与历史 backfill 使用独立连接放宽,事务可通过局部设置覆盖
|
||||
- `AETHER_USAGE_EVENT_CAPTURE_MEMORY_BUDGET_BYTES`:usage 诊断正文共享预算,默认 `134217728`(128 MiB),按 JSON 堆内存估算,覆盖进入终态队列的 seed、Redis 解码后的事件、数据库写入 DTO 及其正文副本。额度不足或显式 `0` 时先保留计费事实,再舍弃诊断正文;已有清空或禁用状态保持不变,其余标记截断。预算随正文保留到释放,后台构建或压缩不会因调用方取消而提前归还额度。该额度不覆盖原始 Redis 批次、解码临时分配、序列化及压缩结果、协议观察缓冲或进程总内存;可通过 `usage_runtime_event_capture_memory_*` 指标观察
|
||||
- `AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES`:新增 usage 队列消息的完整 JSON payload 上限,默认 `1048576`(1 MiB),按序列化后的 UTF-8 字节计算,显式 `0` 非法。超限先保留计费事实并舍弃诊断字段;仍超限或无法保留计费语义时拒绝入队,终态消息尝试受限数据库落库,失败则明确失败,不继续 Redis 重试。该限制不覆盖存量 Redis 消息、整个读取批次、DLQ 或进程总内存。`usage_runtime_queue_payload_*` 导出上限及进程级降级、拒绝编码尝试次数,包含入队和重试预校验,不代表唯一事件数;`usage_runtime_enqueue_retry_permanent_failure_total` 记录永久输入错误导致的重试拒绝或终止
|
||||
- `AETHER_USAGE_QUEUE_READ_PAYLOAD_BUDGET_BYTES` / `AETHER_USAGE_QUEUE_READ_BATCH_PAYLOAD_BYTES`:usage worker 读取和重领共用的进程级逻辑 payload 预留,默认总额 `134217728`(128 MiB)、单批目标 `8388608`(8 MiB)。按当前 `QUEUE_PAYLOAD_MAX_BYTES` 推导实际 COUNT,默认最多读取 8 条,自动扩容使用实际 COUNT 判断批次是否读满。预留覆盖读取、整批处理和确认,额度不足等待;取消/失败释放。单批目标至少允许一条,当前 payload 上限大于总额时读取报配置错误。`0` 或非法值回退默认,过大值收敛到约 4 GiB 的有效总额。收到消息后按全部字段值长度缩减多余预留;历史消息、其他生产者使用更高上限或额外字段可能超出估算,仍继续原计费流程并记录 `usage_runtime_queue_read_oversized_*`。`usage_runtime_queue_read_*` 同时导出预留、等待与累计字段字节;该预留不是 RESP 解码、连接缓冲容量、字段结构、诊断 JSON、DLQ 或进程 RSS 的硬上限,旧公开 Vec 读取接口不携带处理阶段预留
|
||||
- `AETHER_USAGE_DLQ_ENCODING_BUDGET_BYTES` / `AETHER_USAGE_DLQ_ENCODING_MAX_JOBS`:死信原文和 JSON 编码独立共享预留,默认 `67108864`(64 MiB)、最多 `4` 个后台编码及写入任务。根据原始字段、ID、错误字符串及 JSON 最坏 6 倍转义一次预留;预算占满或单条超总额时立即失败,worker 保留原消息等待重领,不截断账务原文。编码失败会继续处理同批其他消息,只确认成功项,批次末尾仍报告失败;存储转移失败则停止该批后续处理。取消编码等待不会提前归还仍在后台使用的额度。`0`/非法值回退默认,bytes 最大约 4 GiB,jobs 最大 128;超大存量消息可能需要调高总额后恢复。`usage_runtime_dlq_encoding_*` 导出额度、在途任务、拒绝和编码尝试次数;不包含字段结构、字符串额外容量、Redis 命令/连接副本或进程 RSS。内置 Redis/Memory worker 将死信追加、源 ACK 和删除作为一次原子转移,同一源 stream、消费组及 pending ID 的并发或重试只追加一次;Redis 要求 7+ 及 `EVAL/TYPE/XPENDING/XADD/XACK/XDEL` 权限,Cluster 两键须同 slot(当前默认键不自动迁移)。源和 DLQ 不能同名。源已不在 PEL 时不宣称已归档;外部 ACK/trim/delete 及多消费组仍有原来的删除语义。公开 `push_dead_letter` 仍为追加接口,未实现新原子 trait 方法的外部后端沿用追加后 ACK,仍可能重复归档
|
||||
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
|
||||
- `AETHER_GATEWAY_MAX_HTTP_CONNECTIONS`:二进制入口全部监听分片共用的入站 TCP 连接上限,包含握手、空闲 keep-alive 和 HTTP 升级后仍存活的 socket。未设置或 `0` 时使用请求上限与 WebSocket 上限之和;自动及显式值均最多 `65536`,已知 FD soft limit 时进一步限制为 `max(1, (FD - 256) / 2)`。接入后立即尝试取得额度,满额时关闭新连接,不创建 HTTP 处理任务、不等待额度,不返回 HTTP 状态码;取消、解析失败和连接释放归还,WebSocket 升级不会提前归还。HTTP/2 多流共用一个 TCP 许可,原请求和 WebSocket 准入仍独立有效。`gateway_http_connections_*` 导出配置上限、当前数、高水位、拒绝数及 accept 错误数。该限制不包含 kernel backlog、上游、Redis 或数据库连接,也不是整个进程 FD/内存硬上限。临时 accept 错误重试,资源类错误退避一秒后重试,避免单次错误停止监听
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`;压缩和未知长度上传按实际缓冲增长申请额度,解压时计入同时存活的输入和输出。额度不足返回 `503`;接近单请求上限的压缩上传需要为输入和解压输出预留额外预算
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:请求体完整读取超时,默认 `120000ms`;显式设为 `0` 时关闭,非零值限制在 `1000-600000ms`
|
||||
- `AETHER_GATEWAY_UPSTREAM_STREAM_IDLE_TIMEOUT_MS`:上游流首包后的空闲超时,默认 `300000ms`;请求执行配置中的 `read_ms` 优先,显式 `0` 关闭对应超时。网关生成的 keepalive 不会重置计时
|
||||
- `AETHER_GATEWAY_STREAM_CAPTURE_MEMORY_BUDGET_BYTES`:进程内流式响应诊断捕获的共享字节预算,默认 `134217728`(128 MiB);包含 provider/client 捕获容量和扩容时的新旧分配。额度不足时仅截断审计副本,显式 `0` 关闭此类捕获;协议解析、客户端传输和计费观察继续执行。该预算不包含协议解析缓冲、终态编码及 usage 队列副本,不是进程总内存上限
|
||||
- `AETHER_MAX_REQUEST_BODY_MB`:单请求解压后请求体上限,默认 `256MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
||||
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:heartbeat、管理探测等内部整包响应体上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
||||
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
|
||||
- `AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS`:跨网关 owner relay 解析到私有/保留地址时的显式运维开关,默认关闭;仅当多网关 relay URL 是受控的内网 HTTPS 地址时设置为 `true`。它不改变普通 provider 请求的 DNS/代理策略,也不允许明文 HTTP 非 loopback relay
|
||||
- `AETHER_TUNNEL_RELAY_PRIVATE_HOST_ALLOWLIST`:更窄的 owner relay 私网例外,填写逗号分隔的精确主机名(例如 `gateway-a.internal,gateway-b.internal`,忽略大小写和末尾点);仅这些主机解析出的私有地址会被允许,并且请求仍使用解析后地址 pin。不要填写通配符或 `.internal` 这类后缀
|
||||
- `AETHER_INTERNAL_GATEWAY_AUTH_SECRET`:旧版 `/api/internal/gateway/*` 高权限控制面的独立 HMAC 密钥,至少 `32` 字节;未配置时该控制面返回 `404`。不要复用 JWT、数据加密或 tunnel relay 密钥,多节点必须使用同一值及共享 Redis 防重放
|
||||
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
|
||||
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:PII 恢复同步响应缓冲上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
||||
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。配置 Redis 时使用 `redis`,否则使用 `memory`;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
|
||||
- `AETHER_GATEWAY_DATABASE_MODE=auto|verify-only`:数据库启动策略,默认 `auto`,自动完成挂起的 schema migration 和 backfill;`verify-only` 仅检查并在数据库落后时拒绝启动
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:旧版兼容开关;新配置请使用 `AETHER_GATEWAY_DATABASE_MODE`
|
||||
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
||||
- `AETHER_BACKUP_ENCRYPTION_KEY`:推荐的 S3 备份独立加密密钥;缺省回退到 `ENCRYPTION_KEY`。新备份使用带 key ID 的 AES-256-GCM v2 envelope,轮换前必须保留旧密钥
|
||||
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
||||
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
|
||||
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
|
||||
- `DB_PASSWORD` / `REDIS_PASSWORD`:Docker Compose 后端密码,首次安装时分别随机生成;手工部署必须替换示例占位值,不要互相复用
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
|
||||
| `REDIS_PASSWORD` | Redis 密码 |
|
||||
| `JWT_SECRET_KEY` | JWT 签名密钥(使用 `generate_keys.py` 生成) |
|
||||
| `ENCRYPTION_KEY` | API Key 加密密钥(更换后需重新配置 Provider Key) |
|
||||
| `ADMIN_EMAIL` | 初始管理员邮箱 |
|
||||
| `ADMIN_USERNAME` | 初始管理员用户名 |
|
||||
| `ADMIN_PASSWORD` | 初始管理员密码 |
|
||||
运行日志由独立后台线程写入 stdout 和文件,每个输出队列最多 4096 条、保留正文最多 8 MiB(包含正在写入的记录),单条最多 256 KiB。队列满、正文预算不足或单条超限时整条丢弃,不等待日志设备;`Both` 两个输出独立降级。`logging_stdout_*` 和 `logging_file_*` 指标记录丢弃和写入错误,网关指标沿用其命名空间前缀。正常退出时日志最多等待 2 秒排空;这不是请求优雅排空或整个进程退出期限。日志格式化仍在调用线程执行,日志预算不包含格式化临时内存,运行日志也不能作为可靠计费账本。
|
||||
|
||||
### 可选配置
|
||||
### S3 备份离线恢复
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `APP_PORT` | 8084 | 应用端口 |
|
||||
| `API_KEY_PREFIX` | sk | API Key 前缀 |
|
||||
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
|
||||
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 |
|
||||
| `DB_PORT` | 5432 | PostgreSQL 端口 |
|
||||
| `REDIS_PORT` | 6379 | Redis 端口 |
|
||||
|
||||
## Q&A
|
||||
|
||||
### Q: 如何开启/关闭请求体记录?
|
||||
|
||||
管理员在 **系统设置** 中配置日志记录的详细程度:
|
||||
|
||||
| 级别 | 记录内容 |
|
||||
|------|----------|
|
||||
| Base | 基本请求信息 |
|
||||
| Headers | Base + 请求头 |
|
||||
| Full | Headers + 请求体 |
|
||||
|
||||
### Q: 更新出问题如何回滚?
|
||||
|
||||
**有备份的情况(推荐):**
|
||||
先从 S3 下载完整的 `.json.zst.aes256gcm` 对象,再使用原始的完整 S3 object key 做认证解密。恢复工具只验证并输出本地 JSON,不会直接写数据库;数据库导入仍应在维护窗口通过管理端完成。
|
||||
|
||||
```bash
|
||||
# 1. 停止应用
|
||||
docker compose stop app
|
||||
|
||||
# 2. 恢复数据库(先清空再导入)
|
||||
docker compose exec -T postgres psql -U postgres -c "DROP DATABASE aether; CREATE DATABASE aether;"
|
||||
gunzip < backup_xxx.sql.gz | docker compose exec -T postgres psql -U postgres -d aether
|
||||
|
||||
# 3. 拉取旧版本镜像并重启
|
||||
# 方式一:使用具体版本 tag(如果有发布版本号)
|
||||
# 将 docker-compose.yml 中 image 从 ghcr.io/fawney19/aether:latest 改为指定版本
|
||||
# 方式二:使用之前记录的镜像 digest
|
||||
# 将 image 改为 ghcr.io/fawney19/aether@sha256:xxxxx
|
||||
docker compose up -d app
|
||||
AETHER_BACKUP_ENCRYPTION_KEY='原备份密钥' \
|
||||
cargo run -p aether-gateway --bin aether-backup-restore -- \
|
||||
--input ./backup.json.zst.aes256gcm \
|
||||
--object-key 'aether/backups/aether-data-backup-20260822-010000.json.zst.aes256gcm' \
|
||||
--output ./restored-backup.json
|
||||
```
|
||||
|
||||
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest,方便回滚时使用。
|
||||
工具默认拒绝覆盖,输出采用原子写并在 Unix 上设置为 `0600`;Unix 可用 `--overwrite` 原子替换,Windows 为避免非原子删除窗口会要求选择新输出路径。密钥不能作为命令行参数。可使用 `AETHER_BACKUP_ENCRYPTION_KEY`、兼容用 `AETHER_GATEWAY_DATA_ENCRYPTION_KEY` / `ENCRYPTION_KEY`、受保护的 `--key-file`,或 `AETHER_BACKUP_KEYRING_FILE`。Keyring JSON 格式为 `{"version":1,"keys":["当前或历史 v2 secret"],"legacy_v1":["旧 v1 secret"]}`;条目也可写成 `{"secret":"..."}`(兼容字段名 `key`)。也可由 `AETHER_BACKUP_HISTORICAL_KEYS_JSON` 提供同一结构。密钥文件必须是非符号链接的普通文件,Unix 下权限需为 `0600` 或更严格。
|
||||
|
||||
**没有备份的情况:**
|
||||
|
||||
```bash
|
||||
# 1. 用当前容器回退数据库迁移(回退 1 步,按需调整数字)
|
||||
docker compose exec app alembic downgrade -1
|
||||
|
||||
# 2. 查看回退后的版本确认正确
|
||||
docker compose exec app alembic current
|
||||
|
||||
# 3. 切回旧镜像并重启(同上方式修改 docker-compose.yml 中的 image)
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
> 注意:没有备份的回滚依赖 alembic downgrade,如果迁移涉及不可逆的数据变更(如删除列),可能无法完全恢复数据。因此强烈建议升级前备份。
|
||||
默认限制密文为 `512MiB`、解压后 JSON 为 `1GiB`,可通过受限的 `--max-encrypted-mib` / `--max-json-mib` 调整。网关最多扫描同一备份前缀下 10,000 个对象,并且不会自动删除 S3 对象:`backup_s3_retention_count` 只用于报告超出保留数量的清理候选。旧明文备份在创建并验证加密副本后仍会保留,必须通过 bucket lifecycle 或支持版本条件的外部清理工具移除;启用 Versioning 时还需清理 noncurrent versions,Object Lock/retention 可能阻止物理删除。
|
||||
|
||||
---
|
||||
|
||||
@@ -182,4 +188,4 @@ docker compose up -d app
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#fawney19/Aether&Date)
|
||||
[](https://www.star-history.com/?repos=fawney19%2FAether&type=date&legend=top-left)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
target/
|
||||
.git/
|
||||
.DS_Store
|
||||
Generated
-1229
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "aether-hub"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
description = "Tunnel Hub for Aether - frame router between workers and proxies"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dashmap = "6"
|
||||
parking_lot = "0.12"
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
@@ -1,34 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.85-slim AS builder
|
||||
WORKDIR /build/aether-hub
|
||||
|
||||
# 可选:配置国内 Cargo 镜像源(本地构建时传 --build-arg CARGO_MIRROR=1)
|
||||
ARG CARGO_MIRROR
|
||||
RUN if [ -n "$CARGO_MIRROR" ]; then \
|
||||
printf '[source.crates-io]\nreplace-with = "tuna"\n\n[source.tuna]\nregistry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"\n' \
|
||||
> /usr/local/cargo/config.toml; \
|
||||
fi
|
||||
|
||||
# 先构建依赖层,最大化后续代码变更时的缓存命中
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && printf 'fn main() {}\n' > src/main.rs
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/build/aether-hub/target,sharing=locked \
|
||||
cargo build --release --locked
|
||||
RUN rm -rf src
|
||||
|
||||
COPY src ./src
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/build/aether-hub/target,sharing=locked \
|
||||
cargo build --release --locked && \
|
||||
cp target/release/aether-hub /tmp/aether-hub
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /tmp/aether-hub /usr/local/bin/aether-hub
|
||||
|
||||
EXPOSE 8085
|
||||
ENTRYPOINT ["/usr/local/bin/aether-hub"]
|
||||
CMD ["--bind", "0.0.0.0:8085"]
|
||||
@@ -1,54 +0,0 @@
|
||||
# aether-hub
|
||||
|
||||
`aether-hub` 是 Tunnel Hub 服务,负责在 proxy 与 worker 之间路由帧。
|
||||
|
||||
## 快速命令
|
||||
|
||||
### 1) 构建并上传 Hub 二进制(推荐生产)
|
||||
|
||||
```bash
|
||||
cd aether-hub
|
||||
./build.sh --upload hub-v0.1.0
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 默认会构建 `amd64 + arm64` 两个二进制并上传到 GitHub Release。
|
||||
- 如只需单架构,可先 `./build.sh amd64` 或 `./build.sh arm64`。
|
||||
|
||||
### 2) 部署端指定 Hub 版本并构建
|
||||
|
||||
```bash
|
||||
cd /path/to/Aether
|
||||
./deploy.sh --hub-tag hub-v0.1.0
|
||||
```
|
||||
|
||||
不指定 `--hub-tag` 时,`./deploy.sh` 会自动解析最新 `hub-v*` release,并在构建 app 镜像时从 GitHub Release 下载对应架构的 Hub 二进制。
|
||||
|
||||
### 3) 镜像模式(可选,调试/实验用)
|
||||
|
||||
仅本地加载镜像(单平台):
|
||||
|
||||
```bash
|
||||
cd aether-hub
|
||||
BUILDKIT_PROGRESS=plain ./build.sh --image --tag local-test --platforms linux/amd64 --load
|
||||
```
|
||||
|
||||
## build.sh 模式说明
|
||||
|
||||
- 默认是 `binary` 模式(`cross` 构建二进制)。
|
||||
- `--upload <hub-vX.Y.Z>` 会把构建产物上传到 GitHub Release。
|
||||
- 加 `--image` 后进入镜像模式(`docker buildx`,可选)。
|
||||
|
||||
常用参数:
|
||||
|
||||
- `--tag <tag>`: 镜像 tag
|
||||
- `--image-name <name>`: 镜像名(默认 `ghcr.io/fawney19/aether-hub`)
|
||||
- `--platforms <list>`: 例如 `linux/amd64,linux/arm64`
|
||||
- `--push`: 推送镜像
|
||||
- `--load`: 加载到本地 Docker(单平台)
|
||||
- `--latest`: 额外打 `latest` tag
|
||||
|
||||
## 与部署脚本关系
|
||||
|
||||
- `./deploy.sh`: 本地构建部署(会本地构建 app/base,并在构建 app 时从 GitHub Release 下载 Hub,可用 `--hub-tag` 固定版本)。
|
||||
@@ -1,272 +0,0 @@
|
||||
#!/bin/bash
|
||||
# aether-hub 构建脚本
|
||||
#
|
||||
# 支持两种模式:
|
||||
# 1) binary 模式(默认): 构建多架构二进制并可上传 GitHub Release
|
||||
# 2) image 模式: 构建并推送/加载 Docker 镜像(推荐生产发布用)
|
||||
#
|
||||
# 示例:
|
||||
# # binary 模式(兼容旧行为)
|
||||
# ./build.sh
|
||||
# ./build.sh amd64
|
||||
# ./build.sh --upload hub-v0.1.0
|
||||
#
|
||||
# # image 模式(多架构推送)
|
||||
# ./build.sh --image --tag v0.2.5 --push --latest
|
||||
# ./build.sh --image --tag sha-abc123 --image-name ghcr.io/fawney19/aether-hub --push
|
||||
# ./build.sh --image --tag local-test --platforms linux/amd64 --load
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
|
||||
# -------------------------------
|
||||
# Defaults
|
||||
# -------------------------------
|
||||
MODE="binary" # binary | image
|
||||
|
||||
# binary mode options
|
||||
UPLOAD=false
|
||||
UPLOAD_TAG=""
|
||||
BINARY_TARGETS=""
|
||||
|
||||
# image mode options
|
||||
IMAGE_NAME="${IMAGE_NAME:-ghcr.io/fawney19/aether-hub}"
|
||||
IMAGE_TAG=""
|
||||
IMAGE_PLATFORMS="linux/amd64,linux/arm64"
|
||||
IMAGE_PUSH=false
|
||||
IMAGE_LOAD=false
|
||||
IMAGE_LATEST=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./build.sh [binary-args]
|
||||
./build.sh --image [image-args]
|
||||
|
||||
binary 模式(默认):
|
||||
amd64|arm64 仅构建指定架构(可重复)
|
||||
--upload <hub-vX.Y.Z> 上传到 GitHub Release(需要 gh CLI)
|
||||
|
||||
image 模式:
|
||||
--image 启用镜像模式
|
||||
--tag <tag> 镜像 tag(默认自动从 git describe 推导)
|
||||
--image-name <name> 镜像名(默认 ghcr.io/fawney19/aether-hub)
|
||||
--platforms <list> 平台列表,逗号分隔(默认 linux/amd64,linux/arm64)
|
||||
--push 推送镜像到仓库
|
||||
--load 加载到本地 Docker(仅单平台)
|
||||
--latest 额外打 latest tag
|
||||
|
||||
通用:
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--image)
|
||||
MODE="image"
|
||||
shift
|
||||
;;
|
||||
--tag)
|
||||
IMAGE_TAG="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--image-name)
|
||||
IMAGE_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--platforms)
|
||||
IMAGE_PLATFORMS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--push)
|
||||
IMAGE_PUSH=true
|
||||
shift
|
||||
;;
|
||||
--load)
|
||||
IMAGE_LOAD=true
|
||||
shift
|
||||
;;
|
||||
--latest)
|
||||
IMAGE_LATEST=true
|
||||
shift
|
||||
;;
|
||||
--upload)
|
||||
UPLOAD=true
|
||||
UPLOAD_TAG="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
amd64|arm64)
|
||||
BINARY_TARGETS="$BINARY_TARGETS $1"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ 未知参数: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build_binary() {
|
||||
if [ -z "$BINARY_TARGETS" ]; then
|
||||
BINARY_TARGETS="amd64 arm64"
|
||||
fi
|
||||
|
||||
if ! command -v cross >/dev/null 2>&1; then
|
||||
echo "❌ 需要安装 cross: cargo install cross --git https://github.com/cross-rs/cross"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "🔨 开始构建 aether-hub 二进制..."
|
||||
echo " 目标平台: $BINARY_TARGETS"
|
||||
echo ""
|
||||
|
||||
ARTIFACTS=""
|
||||
for arch in $BINARY_TARGETS; do
|
||||
case "$arch" in
|
||||
amd64) target="x86_64-unknown-linux-gnu" ;;
|
||||
arm64) target="aarch64-unknown-linux-gnu" ;;
|
||||
*) echo "❌ 未知架构: $arch"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo ">>> 构建 $arch ($target)..."
|
||||
cd "$SCRIPT_DIR"
|
||||
cross build --release --target "$target" --locked
|
||||
|
||||
BIN="target/$target/release/aether-hub"
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "❌ 未找到二进制文件: $BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE="$DIST_DIR/aether-hub-linux-$arch.tar.gz"
|
||||
tar czf "$ARCHIVE" -C "target/$target/release" aether-hub
|
||||
ARTIFACTS="$ARTIFACTS $ARCHIVE"
|
||||
|
||||
SIZE=$(du -h "$ARCHIVE" | cut -f1)
|
||||
echo "✅ $arch 构建完成: $ARCHIVE ($SIZE)"
|
||||
echo ""
|
||||
done
|
||||
|
||||
cd "$DIST_DIR"
|
||||
shasum -a 256 aether-hub-*.tar.gz > SHA256SUMS.txt
|
||||
echo "📋 SHA256 校验和:"
|
||||
cat SHA256SUMS.txt
|
||||
echo ""
|
||||
|
||||
if [ "$UPLOAD" = true ]; then
|
||||
if [ -z "$UPLOAD_TAG" ]; then
|
||||
echo "❌ --upload 需要指定 tag,例如: ./build.sh --upload hub-v0.1.0"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "❌ 需要安装 GitHub CLI: brew install gh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 上传到 GitHub Release: $UPLOAD_TAG"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if ! git rev-parse "$UPLOAD_TAG" >/dev/null 2>&1; then
|
||||
git tag "$UPLOAD_TAG"
|
||||
git push origin "$UPLOAD_TAG"
|
||||
fi
|
||||
|
||||
gh release create "$UPLOAD_TAG" \
|
||||
--title "aether-hub ${UPLOAD_TAG#hub-}" \
|
||||
--generate-notes \
|
||||
$ARTIFACTS \
|
||||
"$DIST_DIR/SHA256SUMS.txt"
|
||||
|
||||
echo "✅ 上传完成!"
|
||||
fi
|
||||
|
||||
echo "🎉 binary 模式完成!"
|
||||
}
|
||||
|
||||
build_image() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "❌ 未找到 docker,请先安装 Docker"
|
||||
exit 1
|
||||
fi
|
||||
if ! docker buildx version >/dev/null 2>&1; then
|
||||
echo "❌ 未找到 docker buildx,请先启用 buildx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_PUSH" = true ] && [ "$IMAGE_LOAD" = true ]; then
|
||||
echo "❌ --push 与 --load 不能同时使用"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_PUSH" = false ] && [ "$IMAGE_LOAD" = false ]; then
|
||||
# image 模式默认走 push,符合发布场景
|
||||
IMAGE_PUSH=true
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
IMAGE_TAG=$(git -C "$PROJECT_DIR" describe --tags --always 2>/dev/null | sed 's/^v//')
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
IMAGE_TAG=$(date +%Y%m%d%H%M%S)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_LOAD" = true ] && [[ "$IMAGE_PLATFORMS" == *,* ]]; then
|
||||
echo "❌ --load 仅支持单平台,请用 --platforms linux/amd64(或 arm64)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local ref="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
local cmd=(docker buildx build
|
||||
--platform "$IMAGE_PLATFORMS"
|
||||
-f "$SCRIPT_DIR/Dockerfile"
|
||||
-t "$ref"
|
||||
)
|
||||
|
||||
if [ "$IMAGE_LATEST" = true ]; then
|
||||
cmd+=(-t "${IMAGE_NAME}:latest")
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_PUSH" = true ]; then
|
||||
cmd+=(--push)
|
||||
else
|
||||
cmd+=(--load)
|
||||
fi
|
||||
|
||||
cmd+=("$SCRIPT_DIR")
|
||||
|
||||
echo "🔨 开始构建 aether-hub 镜像..."
|
||||
echo " image: $ref"
|
||||
echo " platforms: $IMAGE_PLATFORMS"
|
||||
echo " mode: $([ "$IMAGE_PUSH" = true ] && echo push || echo load)"
|
||||
echo ""
|
||||
|
||||
"${cmd[@]}"
|
||||
|
||||
if [ "$IMAGE_PUSH" = true ]; then
|
||||
echo "✅ 镜像已推送: $ref"
|
||||
if [ "$IMAGE_LATEST" = true ]; then
|
||||
echo "✅ 镜像已推送: ${IMAGE_NAME}:latest"
|
||||
fi
|
||||
else
|
||||
echo "✅ 镜像已加载到本地: $ref"
|
||||
fi
|
||||
|
||||
echo "🎉 image 模式完成!"
|
||||
}
|
||||
|
||||
if [ "$MODE" = "image" ]; then
|
||||
build_image
|
||||
else
|
||||
build_binary
|
||||
fi
|
||||
@@ -1,718 +0,0 @@
|
||||
/// HubRouter -- central frame routing engine
|
||||
///
|
||||
/// Manages proxy connections (node_id -> [ProxyConn]) and worker connections (conn_id -> WorkerConn).
|
||||
/// Routes frames between workers and proxies with stream_id remapping.
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::Message;
|
||||
use dashmap::DashMap;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::protocol;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ProxyConn {
|
||||
pub id: u64,
|
||||
pub node_id: String,
|
||||
pub node_name: String,
|
||||
pub tx: mpsc::UnboundedSender<Message>,
|
||||
next_stream_id: AtomicU32,
|
||||
pub stream_count: AtomicUsize,
|
||||
pub max_streams: usize,
|
||||
}
|
||||
|
||||
impl ProxyConn {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
node_id: String,
|
||||
node_name: String,
|
||||
tx: mpsc::UnboundedSender<Message>,
|
||||
max_streams: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
node_id,
|
||||
node_name,
|
||||
tx,
|
||||
next_stream_id: AtomicU32::new(2), // even IDs, start at 2
|
||||
stream_count: AtomicUsize::new(0),
|
||||
max_streams,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate a proxy-side stream_id (even numbers)
|
||||
pub fn alloc_stream_id(&self) -> Option<u32> {
|
||||
// Reserve one stream slot first (CAS to honor max_streams under contention).
|
||||
let mut current = self.stream_count.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current >= self.max_streams {
|
||||
return None;
|
||||
}
|
||||
match self.stream_count.compare_exchange_weak(
|
||||
current,
|
||||
current + 1,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
|
||||
let sid = loop {
|
||||
let current_sid = self.next_stream_id.load(Ordering::Relaxed);
|
||||
let next_sid = if current_sid >= 0xFFFF_FFFE {
|
||||
2
|
||||
} else {
|
||||
current_sid + 2
|
||||
};
|
||||
if self
|
||||
.next_stream_id
|
||||
.compare_exchange_weak(current_sid, next_sid, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
break current_sid;
|
||||
}
|
||||
};
|
||||
|
||||
Some(sid)
|
||||
}
|
||||
|
||||
pub fn release_stream(&self) {
|
||||
let mut current = self.stream_count.load(Ordering::Relaxed);
|
||||
while current > 0 {
|
||||
match self.stream_count.compare_exchange_weak(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, msg: Message) -> bool {
|
||||
self.tx.send(msg).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct WorkerConn {
|
||||
pub id: u64,
|
||||
pub tx: mpsc::UnboundedSender<Message>,
|
||||
}
|
||||
|
||||
impl WorkerConn {
|
||||
pub fn new(id: u64, tx: mpsc::UnboundedSender<Message>) -> Self {
|
||||
Self { id, tx }
|
||||
}
|
||||
|
||||
pub fn send(&self, msg: Message) -> bool {
|
||||
self.tx.send(msg).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream mapping entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ProxySide {
|
||||
proxy_conn_id: u64,
|
||||
proxy_stream_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct WorkerSide {
|
||||
worker_conn_id: u64,
|
||||
worker_stream_id: u32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HubRouter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct HubRouter {
|
||||
/// node_id -> list of proxy connections
|
||||
proxy_conns: RwLock<std::collections::HashMap<String, Vec<Arc<ProxyConn>>>>,
|
||||
/// proxy_conn_id -> Arc<ProxyConn> (for reverse lookup)
|
||||
proxy_conns_by_id: DashMap<u64, Arc<ProxyConn>>,
|
||||
/// worker_conn_id -> Arc<WorkerConn>
|
||||
worker_conns: DashMap<u64, Arc<WorkerConn>>,
|
||||
/// (worker_conn_id, worker_stream_id) -> ProxySide
|
||||
worker_to_proxy: DashMap<(u64, u32), ProxySide>,
|
||||
/// (proxy_conn_id, proxy_stream_id) -> WorkerSide
|
||||
proxy_to_worker: DashMap<(u64, u32), WorkerSide>,
|
||||
/// Connection ID generator
|
||||
next_conn_id: AtomicU64,
|
||||
/// Round-robin counter for heartbeat forwarding
|
||||
heartbeat_rr: AtomicU64,
|
||||
/// Heartbeat tag -> proxy_conn_id mapping (u32 tag fits in stream_id field)
|
||||
heartbeat_tags: DashMap<u32, u64>,
|
||||
/// Next heartbeat tag (wrapping u32)
|
||||
next_heartbeat_tag: AtomicU32,
|
||||
}
|
||||
|
||||
impl HubRouter {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
proxy_conns: RwLock::new(std::collections::HashMap::new()),
|
||||
proxy_conns_by_id: DashMap::new(),
|
||||
worker_conns: DashMap::new(),
|
||||
worker_to_proxy: DashMap::new(),
|
||||
proxy_to_worker: DashMap::new(),
|
||||
next_conn_id: AtomicU64::new(1),
|
||||
heartbeat_rr: AtomicU64::new(0),
|
||||
heartbeat_tags: DashMap::new(),
|
||||
next_heartbeat_tag: AtomicU32::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn alloc_conn_id(&self) -> u64 {
|
||||
self.next_conn_id.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Proxy connection management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn register_proxy(&self, conn: Arc<ProxyConn>) {
|
||||
let node_id = conn.node_id.clone();
|
||||
let node_name = conn.node_name.clone();
|
||||
let conn_id = conn.id;
|
||||
self.proxy_conns_by_id.insert(conn_id, conn.clone());
|
||||
|
||||
let mut map = self.proxy_conns.write();
|
||||
map.entry(node_id.clone()).or_default().push(conn);
|
||||
let pool_size = map.get(&node_id).map(|v| v.len()).unwrap_or(0);
|
||||
|
||||
info!(
|
||||
node_id = %node_id,
|
||||
node_name = %node_name,
|
||||
conn_id = conn_id,
|
||||
pool_size = pool_size,
|
||||
"proxy connected"
|
||||
);
|
||||
|
||||
drop(map);
|
||||
self.broadcast_node_status(&node_id);
|
||||
}
|
||||
|
||||
pub fn unregister_proxy(&self, conn_id: u64, node_id: &str) {
|
||||
self.proxy_conns_by_id.remove(&conn_id);
|
||||
|
||||
let mut map = self.proxy_conns.write();
|
||||
if let Some(conns) = map.get_mut(node_id) {
|
||||
conns.retain(|c| c.id != conn_id);
|
||||
if conns.is_empty() {
|
||||
map.remove(node_id);
|
||||
}
|
||||
}
|
||||
let pool_size = map.get(node_id).map(|v| v.len()).unwrap_or(0);
|
||||
|
||||
info!(
|
||||
node_id = %node_id,
|
||||
conn_id = conn_id,
|
||||
remaining = pool_size,
|
||||
"proxy disconnected"
|
||||
);
|
||||
|
||||
drop(map);
|
||||
|
||||
// Cancel all in-flight streams on this proxy connection
|
||||
self.cancel_streams_for_proxy(conn_id);
|
||||
|
||||
self.broadcast_node_status(node_id);
|
||||
}
|
||||
|
||||
/// Get least-loaded proxy connection for a node
|
||||
fn get_proxy_conn(&self, node_id: &str) -> Option<Arc<ProxyConn>> {
|
||||
let map = self.proxy_conns.read();
|
||||
let conns = map.get(node_id)?;
|
||||
conns
|
||||
.iter()
|
||||
.min_by_key(|c| c.stream_count.load(Ordering::Relaxed))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get pool size for a node
|
||||
fn proxy_conn_count(&self, node_id: &str) -> usize {
|
||||
let map = self.proxy_conns.read();
|
||||
map.get(node_id).map(|v| v.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Worker connection management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn register_worker(&self, conn: Arc<WorkerConn>) {
|
||||
info!(worker_id = conn.id, "worker connected");
|
||||
self.worker_conns.insert(conn.id, conn.clone());
|
||||
self.sync_node_status_to_worker(&conn);
|
||||
}
|
||||
|
||||
pub fn unregister_worker(&self, conn_id: u64) {
|
||||
self.worker_conns.remove(&conn_id);
|
||||
info!(worker_id = conn_id, "worker disconnected");
|
||||
|
||||
// Clean up all stream mappings for this worker
|
||||
let to_remove: Vec<(u64, u32)> = self
|
||||
.worker_to_proxy
|
||||
.iter()
|
||||
.filter(|e| e.key().0 == conn_id)
|
||||
.map(|e| *e.key())
|
||||
.collect();
|
||||
|
||||
for key in &to_remove {
|
||||
if let Some((_, proxy_side)) = self.worker_to_proxy.remove(key) {
|
||||
self.proxy_to_worker
|
||||
.remove(&(proxy_side.proxy_conn_id, proxy_side.proxy_stream_id));
|
||||
// Release stream count on proxy side
|
||||
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_side.proxy_conn_id) {
|
||||
pc.release_stream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !to_remove.is_empty() {
|
||||
debug!(
|
||||
worker_id = conn_id,
|
||||
streams_cleaned = to_remove.len(),
|
||||
"cleaned up worker streams"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame routing: Worker -> Proxy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Handle a frame from a worker. Returns error message if routing fails.
|
||||
pub fn handle_worker_frame(&self, worker_conn_id: u64, data: &mut [u8]) -> Option<String> {
|
||||
let header = match protocol::FrameHeader::parse(data) {
|
||||
Some(h) => h,
|
||||
None => return Some("invalid frame".to_string()),
|
||||
};
|
||||
let expected_len = protocol::HEADER_SIZE + header.payload_len as usize;
|
||||
if data.len() < expected_len {
|
||||
return Some("incomplete frame payload".to_string());
|
||||
}
|
||||
|
||||
match header.msg_type {
|
||||
protocol::REQUEST_HEADERS => {
|
||||
self.route_request_headers(worker_conn_id, header.stream_id, data)
|
||||
}
|
||||
protocol::REQUEST_BODY => {
|
||||
if header.flags & protocol::FLAG_END_STREAM != 0 {
|
||||
debug!(
|
||||
worker_conn_id = worker_conn_id,
|
||||
stream_id = header.stream_id,
|
||||
"worker sent REQUEST_BODY with END_STREAM"
|
||||
);
|
||||
}
|
||||
self.route_worker_to_proxy(worker_conn_id, header.stream_id, data, false);
|
||||
None
|
||||
}
|
||||
protocol::STREAM_END | protocol::STREAM_ERROR => {
|
||||
self.route_worker_to_proxy(worker_conn_id, header.stream_id, data, true);
|
||||
None
|
||||
}
|
||||
protocol::GOAWAY => {
|
||||
warn!(
|
||||
worker_conn_id = worker_conn_id,
|
||||
"received GOAWAY from worker connection"
|
||||
);
|
||||
None
|
||||
}
|
||||
protocol::PING => {
|
||||
let payload = protocol::frame_payload(data).to_vec();
|
||||
let pong = protocol::encode_pong(&payload);
|
||||
if let Some(wc) = self.worker_conns.get(&worker_conn_id) {
|
||||
let _ = wc.send(Message::Binary(pong.into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
protocol::PONG => None, // Worker responded to our ping, nothing to do
|
||||
_ => {
|
||||
debug!(
|
||||
msg_type = header.msg_type,
|
||||
"unexpected frame type from worker"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route REQUEST_HEADERS: extract node_id, allocate proxy stream, create mapping
|
||||
fn route_request_headers(
|
||||
&self,
|
||||
worker_conn_id: u64,
|
||||
worker_stream_id: u32,
|
||||
data: &mut [u8],
|
||||
) -> Option<String> {
|
||||
// Parse payload to extract node_id, and pre-build frame with node_id stripped.
|
||||
// stream_id is set to 0 first; we'll rewrite to proxy_stream_id after allocation.
|
||||
let extracted = match protocol::rebuild_request_headers_without_node_id(data, 0) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Some(e),
|
||||
};
|
||||
let node_id = extracted.node_id;
|
||||
|
||||
// Find a proxy connection for this node
|
||||
let proxy_conn = match self.get_proxy_conn(&node_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Some(format!("no proxy connection for node {}", node_id));
|
||||
}
|
||||
};
|
||||
|
||||
// Allocate proxy-side stream_id
|
||||
let proxy_stream_id = match proxy_conn.alloc_stream_id() {
|
||||
Some(sid) => sid,
|
||||
None => {
|
||||
return Some(format!("stream limit reached for node {}", node_id));
|
||||
}
|
||||
};
|
||||
|
||||
let mut rebuilt_frame = extracted.rebuilt_frame;
|
||||
protocol::rewrite_stream_id(&mut rebuilt_frame, proxy_stream_id);
|
||||
|
||||
// Record bidirectional mapping
|
||||
self.worker_to_proxy.insert(
|
||||
(worker_conn_id, worker_stream_id),
|
||||
ProxySide {
|
||||
proxy_conn_id: proxy_conn.id,
|
||||
proxy_stream_id,
|
||||
},
|
||||
);
|
||||
self.proxy_to_worker.insert(
|
||||
(proxy_conn.id, proxy_stream_id),
|
||||
WorkerSide {
|
||||
worker_conn_id,
|
||||
worker_stream_id,
|
||||
},
|
||||
);
|
||||
|
||||
if !proxy_conn.send(Message::Binary(rebuilt_frame.into())) {
|
||||
// Send failed, clean up mapping
|
||||
self.worker_to_proxy
|
||||
.remove(&(worker_conn_id, worker_stream_id));
|
||||
self.proxy_to_worker
|
||||
.remove(&(proxy_conn.id, proxy_stream_id));
|
||||
proxy_conn.release_stream();
|
||||
return Some("proxy connection send failed".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Route non-header frames from worker to proxy (REQUEST_BODY etc.)
|
||||
fn route_worker_to_proxy(
|
||||
&self,
|
||||
worker_conn_id: u64,
|
||||
worker_stream_id: u32,
|
||||
data: &mut [u8],
|
||||
terminal: bool,
|
||||
) {
|
||||
let proxy_side = if terminal {
|
||||
match self
|
||||
.worker_to_proxy
|
||||
.remove(&(worker_conn_id, worker_stream_id))
|
||||
{
|
||||
Some((_, ps)) => {
|
||||
self.proxy_to_worker
|
||||
.remove(&(ps.proxy_conn_id, ps.proxy_stream_id));
|
||||
if let Some(pc) = self.proxy_conns_by_id.get(&ps.proxy_conn_id) {
|
||||
pc.release_stream();
|
||||
}
|
||||
ps
|
||||
}
|
||||
None => return, // Silently discard -- mapping already removed (race condition)
|
||||
}
|
||||
} else {
|
||||
match self
|
||||
.worker_to_proxy
|
||||
.get(&(worker_conn_id, worker_stream_id))
|
||||
{
|
||||
Some(entry) => *entry.value(),
|
||||
None => return, // Silently discard -- mapping already removed (race condition)
|
||||
}
|
||||
};
|
||||
|
||||
// Rewrite stream_id
|
||||
protocol::rewrite_stream_id(data, proxy_side.proxy_stream_id);
|
||||
|
||||
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_side.proxy_conn_id) {
|
||||
let _ = pc.send(Message::Binary(data.to_vec().into()));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame routing: Proxy -> Worker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Handle a frame from a proxy connection
|
||||
pub fn handle_proxy_frame(&self, proxy_conn_id: u64, data: &mut [u8]) {
|
||||
let header = match protocol::FrameHeader::parse(data) {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
let expected_len = protocol::HEADER_SIZE + header.payload_len as usize;
|
||||
if data.len() < expected_len {
|
||||
return;
|
||||
}
|
||||
|
||||
match header.msg_type {
|
||||
protocol::RESPONSE_HEADERS | protocol::RESPONSE_BODY => {
|
||||
self.route_proxy_to_worker(proxy_conn_id, header.stream_id, data, false);
|
||||
}
|
||||
_ if header.is_stream_terminal() => {
|
||||
self.route_proxy_to_worker(proxy_conn_id, header.stream_id, data, true);
|
||||
}
|
||||
protocol::HEARTBEAT_DATA => {
|
||||
self.forward_heartbeat_to_worker(proxy_conn_id, data);
|
||||
}
|
||||
protocol::PONG => {} // Proxy responded to our ping
|
||||
protocol::GOAWAY => {
|
||||
warn!(
|
||||
proxy_conn_id = proxy_conn_id,
|
||||
"received GOAWAY from proxy connection"
|
||||
);
|
||||
}
|
||||
protocol::PING => {
|
||||
// Proxy sent a ping, reply with pong
|
||||
let payload = if data.len() > protocol::HEADER_SIZE {
|
||||
&data[protocol::HEADER_SIZE..]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let pong = protocol::encode_pong(payload);
|
||||
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_conn_id) {
|
||||
let _ = pc.send(Message::Binary(pong.into()));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
msg_type = header.msg_type,
|
||||
proxy_conn_id = proxy_conn_id,
|
||||
"unexpected frame type from proxy"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route response frames from proxy to worker
|
||||
fn route_proxy_to_worker(
|
||||
&self,
|
||||
proxy_conn_id: u64,
|
||||
proxy_stream_id: u32,
|
||||
data: &mut [u8],
|
||||
terminal: bool,
|
||||
) {
|
||||
let worker_side = if terminal {
|
||||
// Remove mapping on terminal frames
|
||||
match self
|
||||
.proxy_to_worker
|
||||
.remove(&(proxy_conn_id, proxy_stream_id))
|
||||
{
|
||||
Some((_, ws)) => {
|
||||
self.worker_to_proxy
|
||||
.remove(&(ws.worker_conn_id, ws.worker_stream_id));
|
||||
// Release stream count
|
||||
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_conn_id) {
|
||||
pc.release_stream();
|
||||
}
|
||||
ws
|
||||
}
|
||||
None => return, // Silently discard
|
||||
}
|
||||
} else {
|
||||
match self.proxy_to_worker.get(&(proxy_conn_id, proxy_stream_id)) {
|
||||
Some(entry) => *entry.value(),
|
||||
None => return, // Silently discard
|
||||
}
|
||||
};
|
||||
|
||||
// Rewrite stream_id to worker-side
|
||||
protocol::rewrite_stream_id(data, worker_side.worker_stream_id);
|
||||
|
||||
if let Some(wc) = self.worker_conns.get(&worker_side.worker_conn_id) {
|
||||
let _ = wc.send(Message::Binary(data.to_vec().into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward HEARTBEAT_DATA to a worker (round-robin)
|
||||
fn forward_heartbeat_to_worker(&self, proxy_conn_id: u64, data: &[u8]) {
|
||||
// Pick a worker via round-robin
|
||||
let workers: Vec<Arc<WorkerConn>> = self
|
||||
.worker_conns
|
||||
.iter()
|
||||
.map(|e| e.value().clone())
|
||||
.collect();
|
||||
if workers.is_empty() {
|
||||
debug!("no workers to forward heartbeat to");
|
||||
return;
|
||||
}
|
||||
let idx = self.heartbeat_rr.fetch_add(1, Ordering::Relaxed) as usize % workers.len();
|
||||
let worker = &workers[idx];
|
||||
|
||||
// Use a u32 tag in the stream_id field to identify the proxy connection.
|
||||
// The tag maps to the full u64 proxy_conn_id via heartbeat_tags DashMap,
|
||||
// avoiding truncation of u64 conn_id to u32.
|
||||
// Skip 0 (reserved for control frames) via CAS loop.
|
||||
let tag = loop {
|
||||
let t = self.next_heartbeat_tag.fetch_add(1, Ordering::Relaxed);
|
||||
if t != 0 {
|
||||
break t;
|
||||
}
|
||||
};
|
||||
self.heartbeat_tags.insert(tag, proxy_conn_id);
|
||||
|
||||
let mut forwarded = data.to_vec();
|
||||
protocol::rewrite_stream_id(&mut forwarded, tag);
|
||||
|
||||
let _ = worker.send(Message::Binary(forwarded.into()));
|
||||
}
|
||||
|
||||
/// Handle HEARTBEAT_ACK from worker -- route back to the proxy
|
||||
pub fn handle_worker_heartbeat_ack(&self, data: &mut [u8]) {
|
||||
let header = match protocol::FrameHeader::parse(data) {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Recover the original proxy_conn_id from the tag stored in stream_id
|
||||
let tag = header.stream_id;
|
||||
let proxy_conn_id = match self.heartbeat_tags.remove(&tag) {
|
||||
Some((_, id)) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Reset stream_id to 0 before forwarding to proxy
|
||||
protocol::rewrite_stream_id(data, 0);
|
||||
|
||||
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_conn_id) {
|
||||
let _ = pc.send(Message::Binary(data.to_vec().into()));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stream cleanup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Cancel all in-flight streams for a disconnected proxy connection
|
||||
fn cancel_streams_for_proxy(&self, proxy_conn_id: u64) {
|
||||
let to_remove: Vec<((u64, u32), WorkerSide)> = self
|
||||
.proxy_to_worker
|
||||
.iter()
|
||||
.filter(|e| e.key().0 == proxy_conn_id)
|
||||
.map(|e| (*e.key(), *e.value()))
|
||||
.collect();
|
||||
|
||||
for ((p_conn_id, p_sid), worker_side) in &to_remove {
|
||||
self.proxy_to_worker.remove(&(*p_conn_id, *p_sid));
|
||||
self.worker_to_proxy
|
||||
.remove(&(worker_side.worker_conn_id, worker_side.worker_stream_id));
|
||||
|
||||
// Send STREAM_ERROR to worker
|
||||
let err_frame =
|
||||
protocol::encode_stream_error(worker_side.worker_stream_id, "proxy disconnected");
|
||||
if let Some(wc) = self.worker_conns.get(&worker_side.worker_conn_id) {
|
||||
let _ = wc.send(Message::Binary(err_frame.into()));
|
||||
}
|
||||
}
|
||||
|
||||
if !to_remove.is_empty() {
|
||||
warn!(
|
||||
proxy_conn_id = proxy_conn_id,
|
||||
streams_cancelled = to_remove.len(),
|
||||
"cancelled in-flight streams due to proxy disconnect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NODE_STATUS broadcast
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn broadcast_node_status(&self, node_id: &str) {
|
||||
let conn_count = self.proxy_conn_count(node_id);
|
||||
let connected = conn_count > 0;
|
||||
let frame = protocol::encode_node_status(node_id, connected, conn_count);
|
||||
let msg = Message::Binary(frame.into());
|
||||
|
||||
let mut sent = 0usize;
|
||||
for entry in self.worker_conns.iter() {
|
||||
if entry.value().send(msg.clone()) {
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
node_id = %node_id,
|
||||
connected = connected,
|
||||
conn_count = conn_count,
|
||||
workers_notified = sent,
|
||||
"broadcast NODE_STATUS"
|
||||
);
|
||||
}
|
||||
|
||||
/// When a worker connects, sync all current node statuses so worker state
|
||||
/// is consistent even if proxies connected before this worker came online.
|
||||
fn sync_node_status_to_worker(&self, worker: &Arc<WorkerConn>) {
|
||||
let snapshot: Vec<(String, usize)> = {
|
||||
let map = self.proxy_conns.read();
|
||||
map.iter()
|
||||
.map(|(node_id, conns)| (node_id.clone(), conns.len()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (node_id, conn_count) in &snapshot {
|
||||
let frame = protocol::encode_node_status(node_id, *conn_count > 0, *conn_count);
|
||||
let _ = worker.send(Message::Binary(frame.into()));
|
||||
}
|
||||
|
||||
debug!(
|
||||
worker_id = worker.id,
|
||||
nodes_synced = snapshot.len(),
|
||||
"synced NODE_STATUS snapshot to worker"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stats
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn stats(&self) -> HubStats {
|
||||
let proxy_conns = self.proxy_conns.read();
|
||||
let total_proxy = proxy_conns.values().map(|v| v.len()).sum();
|
||||
let nodes = proxy_conns.len();
|
||||
drop(proxy_conns);
|
||||
|
||||
HubStats {
|
||||
proxy_connections: total_proxy,
|
||||
worker_connections: self.worker_conns.len(),
|
||||
nodes,
|
||||
active_streams: self.worker_to_proxy.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct HubStats {
|
||||
pub proxy_connections: usize,
|
||||
pub worker_connections: usize,
|
||||
pub nodes: usize,
|
||||
pub active_streams: usize,
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
mod hub;
|
||||
mod protocol;
|
||||
mod proxy_conn;
|
||||
mod worker_conn;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::WebSocketUpgrade;
|
||||
use axum::extract::State;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::hub::HubRouter;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "aether-hub", about = "Tunnel Hub for Aether")]
|
||||
struct Args {
|
||||
/// Bind address
|
||||
#[arg(long, default_value = "0.0.0.0:8085", env = "TUNNEL_HUB_BIND")]
|
||||
bind: String,
|
||||
|
||||
/// Proxy-side idle timeout in seconds
|
||||
#[arg(long, default_value_t = 90, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
|
||||
proxy_idle_timeout: u64,
|
||||
|
||||
/// Worker-side idle timeout in seconds
|
||||
#[arg(long, default_value_t = 60, env = "TUNNEL_HUB_WORKER_IDLE_TIMEOUT")]
|
||||
worker_idle_timeout: u64,
|
||||
|
||||
/// Ping interval in seconds (for both sides)
|
||||
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
|
||||
ping_interval: u64,
|
||||
|
||||
/// Max concurrent streams per proxy connection
|
||||
#[arg(long, default_value_t = 2048, env = "TUNNEL_HUB_MAX_STREAMS")]
|
||||
max_streams: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
hub: Arc<HubRouter>,
|
||||
proxy_idle_timeout: Duration,
|
||||
worker_idle_timeout: Duration,
|
||||
ping_interval: Duration,
|
||||
max_streams: usize,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "aether_hub=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let hub = HubRouter::new();
|
||||
let state = AppState {
|
||||
hub,
|
||||
proxy_idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
|
||||
worker_idle_timeout: Duration::from_secs(args.worker_idle_timeout),
|
||||
ping_interval: Duration::from_secs(args.ping_interval),
|
||||
max_streams: args.max_streams,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/stats", get(stats))
|
||||
.route("/proxy", get(ws_proxy))
|
||||
.route("/worker", get(ws_worker))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
||||
info!(bind = %args.bind, "aether-hub started");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
Json(serde_json::json!({"status": "ok"}))
|
||||
}
|
||||
|
||||
async fn stats(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Json(state.hub.stats())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn ws_proxy(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let node_id = headers
|
||||
.get("x-node-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let node_name = headers
|
||||
.get("x-node-name")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(&node_id)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let max_streams: usize = headers
|
||||
.get("x-tunnel-max-streams")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(state.max_streams)
|
||||
.clamp(64, 2048);
|
||||
|
||||
if node_id.is_empty() {
|
||||
warn!("proxy connection rejected: missing X-Node-ID header");
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
ws.max_frame_size(64 * 1024 * 1024)
|
||||
.on_upgrade(move |socket| {
|
||||
proxy_conn::handle_proxy_connection(
|
||||
socket,
|
||||
state.hub,
|
||||
node_id,
|
||||
node_name,
|
||||
max_streams,
|
||||
state.ping_interval,
|
||||
state.proxy_idle_timeout,
|
||||
)
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ws_worker(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
ws.max_frame_size(64 * 1024 * 1024)
|
||||
.on_upgrade(move |socket| {
|
||||
worker_conn::handle_worker_connection(
|
||||
socket,
|
||||
state.hub,
|
||||
state.ping_interval,
|
||||
state.worker_idle_timeout,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/// Tunnel binary frame protocol
|
||||
///
|
||||
/// Frame format (10-byte header + payload):
|
||||
/// | stream_id (4B) | msg_type (1B) | flags (1B) | payload_len (4B) | payload (NB) |
|
||||
use std::io::Read;
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
|
||||
pub const HEADER_SIZE: usize = 10;
|
||||
|
||||
// Message types
|
||||
pub const REQUEST_HEADERS: u8 = 0x01;
|
||||
pub const REQUEST_BODY: u8 = 0x02;
|
||||
pub const RESPONSE_HEADERS: u8 = 0x03;
|
||||
pub const RESPONSE_BODY: u8 = 0x04;
|
||||
pub const STREAM_END: u8 = 0x05;
|
||||
pub const STREAM_ERROR: u8 = 0x06;
|
||||
pub const PING: u8 = 0x10;
|
||||
pub const PONG: u8 = 0x11;
|
||||
pub const GOAWAY: u8 = 0x12;
|
||||
pub const HEARTBEAT_DATA: u8 = 0x13;
|
||||
pub const HEARTBEAT_ACK: u8 = 0x14;
|
||||
pub const NODE_STATUS: u8 = 0x15;
|
||||
|
||||
// Flags
|
||||
pub const FLAG_END_STREAM: u8 = 0x01;
|
||||
pub const FLAG_GZIP_COMPRESSED: u8 = 0x02;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FrameHeader {
|
||||
pub stream_id: u32,
|
||||
pub msg_type: u8,
|
||||
pub flags: u8,
|
||||
pub payload_len: u32,
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
/// Parse frame header from raw bytes (must be >= HEADER_SIZE)
|
||||
#[inline]
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() < HEADER_SIZE {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
stream_id: u32::from_be_bytes([data[0], data[1], data[2], data[3]]),
|
||||
msg_type: data[4],
|
||||
flags: data[5],
|
||||
payload_len: u32::from_be_bytes([data[6], data[7], data[8], data[9]]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if this is a stream-terminating frame
|
||||
#[inline]
|
||||
pub fn is_stream_terminal(&self) -> bool {
|
||||
self.msg_type == STREAM_END || self.msg_type == STREAM_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RequestHeadersExtracted {
|
||||
pub node_id: String,
|
||||
pub rebuilt_frame: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Encode a STREAM_ERROR frame for a given stream_id with an error message
|
||||
pub fn encode_stream_error(stream_id: u32, msg: &str) -> Vec<u8> {
|
||||
let payload = msg.as_bytes();
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
|
||||
buf.extend_from_slice(&stream_id.to_be_bytes());
|
||||
buf.push(STREAM_ERROR);
|
||||
buf.push(0); // flags
|
||||
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
buf.extend_from_slice(payload);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode a NODE_STATUS frame (stream_id=0, Hub-generated)
|
||||
pub fn encode_node_status(node_id: &str, connected: bool, conn_count: usize) -> Vec<u8> {
|
||||
let payload = serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"connected": connected,
|
||||
"conn_count": conn_count,
|
||||
});
|
||||
let payload_bytes = payload.to_string().into_bytes();
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload_bytes.len());
|
||||
buf.extend_from_slice(&0u32.to_be_bytes()); // stream_id = 0
|
||||
buf.push(NODE_STATUS);
|
||||
buf.push(0); // flags
|
||||
buf.extend_from_slice(&(payload_bytes.len() as u32).to_be_bytes());
|
||||
buf.extend_from_slice(&payload_bytes);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode a PING frame (stream_id=0)
|
||||
pub fn encode_ping() -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf.push(PING);
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode a PONG frame (stream_id=0, echo payload)
|
||||
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf.push(PONG);
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
buf.extend_from_slice(payload);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode a GOAWAY frame (stream_id=0)
|
||||
pub fn encode_goaway() -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf.push(GOAWAY);
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Rewrite the stream_id in raw frame bytes (first 4 bytes) -- near zero-copy
|
||||
#[inline]
|
||||
pub fn rewrite_stream_id(data: &mut [u8], new_stream_id: u32) {
|
||||
let bytes = new_stream_id.to_be_bytes();
|
||||
data[0] = bytes[0];
|
||||
data[1] = bytes[1];
|
||||
data[2] = bytes[2];
|
||||
data[3] = bytes[3];
|
||||
}
|
||||
|
||||
/// Get the payload portion of a raw frame (after the 10-byte header)
|
||||
#[inline]
|
||||
pub fn frame_payload(data: &[u8]) -> &[u8] {
|
||||
if data.len() > HEADER_SIZE {
|
||||
&data[HEADER_SIZE..]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse REQUEST_HEADERS payload, extract `node_id`, strip it from JSON,
|
||||
/// and rebuild a new REQUEST_HEADERS frame with `new_stream_id`.
|
||||
///
|
||||
/// If the source frame is gzip-compressed, this function will decode it first,
|
||||
/// then try to re-encode with gzip (only keeps compression when payload shrinks).
|
||||
pub fn rebuild_request_headers_without_node_id(
|
||||
data: &[u8],
|
||||
new_stream_id: u32,
|
||||
) -> Result<RequestHeadersExtracted, String> {
|
||||
let header = FrameHeader::parse(data).ok_or_else(|| "invalid frame header".to_string())?;
|
||||
if header.msg_type != REQUEST_HEADERS {
|
||||
return Err("frame is not REQUEST_HEADERS".to_string());
|
||||
}
|
||||
|
||||
let payload = frame_payload_by_header(data, &header)
|
||||
.ok_or_else(|| "incomplete REQUEST_HEADERS payload".to_string())?;
|
||||
|
||||
let decoded_payload = if header.flags & FLAG_GZIP_COMPRESSED != 0 {
|
||||
let mut decoder = GzDecoder::new(payload);
|
||||
let mut decoded = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|e| format!("failed to decompress REQUEST_HEADERS: {e}"))?;
|
||||
decoded
|
||||
} else {
|
||||
payload.to_vec()
|
||||
};
|
||||
|
||||
let mut meta: serde_json::Value = serde_json::from_slice(&decoded_payload)
|
||||
.map_err(|e| format!("invalid REQUEST_HEADERS JSON: {e}"))?;
|
||||
let obj = meta
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "REQUEST_HEADERS payload must be a JSON object".to_string())?;
|
||||
|
||||
let node_id = obj
|
||||
.remove("node_id")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| "missing node_id in REQUEST_HEADERS".to_string())?;
|
||||
|
||||
let stripped_payload = serde_json::to_vec(&meta)
|
||||
.map_err(|e| format!("failed to encode REQUEST_HEADERS payload: {e}"))?;
|
||||
let (final_payload, flags) =
|
||||
maybe_recompress_payload(&stripped_payload, header.flags & FLAG_GZIP_COMPRESSED != 0)
|
||||
.map_err(|e| format!("failed to recompress REQUEST_HEADERS payload: {e}"))?;
|
||||
|
||||
let mut rebuilt = Vec::with_capacity(HEADER_SIZE + final_payload.len());
|
||||
rebuilt.extend_from_slice(&new_stream_id.to_be_bytes());
|
||||
rebuilt.push(REQUEST_HEADERS);
|
||||
rebuilt.push(flags);
|
||||
rebuilt.extend_from_slice(&(final_payload.len() as u32).to_be_bytes());
|
||||
rebuilt.extend_from_slice(&final_payload);
|
||||
|
||||
Ok(RequestHeadersExtracted {
|
||||
node_id,
|
||||
rebuilt_frame: rebuilt,
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&'a [u8]> {
|
||||
let payload_len = header.payload_len as usize;
|
||||
let end = HEADER_SIZE.checked_add(payload_len)?;
|
||||
if data.len() < end {
|
||||
return None;
|
||||
}
|
||||
Some(&data[HEADER_SIZE..end])
|
||||
}
|
||||
|
||||
fn maybe_recompress_payload(
|
||||
payload: &[u8],
|
||||
prefer_gzip: bool,
|
||||
) -> Result<(Vec<u8>, u8), std::io::Error> {
|
||||
if !prefer_gzip {
|
||||
return Ok((payload.to_vec(), 0));
|
||||
}
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload)?;
|
||||
let compressed = encoder.finish()?;
|
||||
if compressed.len() < payload.len() {
|
||||
Ok((compressed, FLAG_GZIP_COMPRESSED))
|
||||
} else {
|
||||
Ok((payload.to_vec(), 0))
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/// Proxy-side WebSocket connection handler
|
||||
///
|
||||
/// Handles the lifecycle of a single aether-proxy connection:
|
||||
/// accept -> authenticate (headers) -> read loop -> cleanup
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::hub::{HubRouter, ProxyConn};
|
||||
use crate::protocol;
|
||||
|
||||
/// Maximum single frame size: 64 MB
|
||||
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
||||
|
||||
pub async fn handle_proxy_connection(
|
||||
ws: WebSocket,
|
||||
hub: Arc<HubRouter>,
|
||||
node_id: String,
|
||||
node_name: String,
|
||||
max_streams: usize,
|
||||
ping_interval: Duration,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
let conn_id = hub.alloc_conn_id();
|
||||
let (mut ws_tx, ws_rx) = ws.split();
|
||||
|
||||
// Create channel for outbound messages
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
|
||||
|
||||
let conn = Arc::new(ProxyConn::new(
|
||||
conn_id,
|
||||
node_id.clone(),
|
||||
node_name.clone(),
|
||||
tx,
|
||||
max_streams,
|
||||
));
|
||||
|
||||
hub.register_proxy(conn.clone());
|
||||
|
||||
// Spawn writer task: drains channel -> WebSocket
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if ws_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
});
|
||||
|
||||
// Spawn ping task
|
||||
let ping_tx = conn.tx.clone();
|
||||
let ping_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(ping_interval).await;
|
||||
let ping = protocol::encode_ping();
|
||||
if ping_tx.send(Message::Binary(ping.into())).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn reader task
|
||||
let reader_hub = hub.clone();
|
||||
let reader_node_id = node_id.clone();
|
||||
let reader_tx = conn.tx.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
run_proxy_reader(
|
||||
ws_rx,
|
||||
reader_hub,
|
||||
conn_id,
|
||||
reader_node_id,
|
||||
reader_tx,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Wait for reader to end, then cleanup writer/ping and unregister from hub.
|
||||
let _ = reader.await;
|
||||
ping_task.abort();
|
||||
writer.abort();
|
||||
hub.unregister_proxy(conn_id, &node_id);
|
||||
}
|
||||
|
||||
async fn run_proxy_reader(
|
||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
||||
hub: Arc<HubRouter>,
|
||||
conn_id: u64,
|
||||
node_id: String,
|
||||
tx: mpsc::UnboundedSender<Message>,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
let mut oversized_count = 0u32;
|
||||
loop {
|
||||
let msg = tokio::select! {
|
||||
msg = ws_rx.next() => msg,
|
||||
_ = tokio::time::sleep(idle_timeout) => {
|
||||
warn!(conn_id = conn_id, node_id = %node_id, "proxy idle timeout");
|
||||
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
let mut data = data.to_vec();
|
||||
if data.len() > MAX_FRAME_SIZE {
|
||||
oversized_count += 1;
|
||||
warn!(
|
||||
conn_id = conn_id,
|
||||
size = data.len(),
|
||||
"oversized frame from proxy"
|
||||
);
|
||||
if oversized_count >= 5 {
|
||||
warn!(conn_id = conn_id, "too many oversized frames, closing");
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
oversized_count = 0;
|
||||
|
||||
if data.len() < protocol::HEADER_SIZE {
|
||||
debug!(conn_id = conn_id, "frame too small, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
hub.handle_proxy_frame(conn_id, &mut data);
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
info!(conn_id = conn_id, node_id = %node_id, "proxy WebSocket closed");
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!(conn_id = conn_id, error = %e, "proxy WebSocket error");
|
||||
break;
|
||||
}
|
||||
_ => {} // Ignore text/ping/pong at WS level
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/// Worker-side WebSocket connection handler
|
||||
///
|
||||
/// Handles the lifecycle of a single Gunicorn worker connection:
|
||||
/// accept -> read loop (route frames via Hub) -> cleanup
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::hub::{HubRouter, WorkerConn};
|
||||
use crate::protocol;
|
||||
|
||||
pub async fn handle_worker_connection(
|
||||
ws: WebSocket,
|
||||
hub: Arc<HubRouter>,
|
||||
ping_interval: Duration,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
let conn_id = hub.alloc_conn_id();
|
||||
let (mut ws_tx, ws_rx) = ws.split();
|
||||
|
||||
// Create channel for outbound messages
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
|
||||
|
||||
let conn = Arc::new(WorkerConn::new(conn_id, tx));
|
||||
hub.register_worker(conn.clone());
|
||||
|
||||
// Spawn writer task
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if ws_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
});
|
||||
|
||||
// Spawn ping task
|
||||
let ping_tx = conn.tx.clone();
|
||||
let ping_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(ping_interval).await;
|
||||
let ping = protocol::encode_ping();
|
||||
if ping_tx.send(Message::Binary(ping.into())).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn reader task
|
||||
let reader_hub = hub.clone();
|
||||
let reader_tx = conn.tx.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
run_worker_reader(
|
||||
ws_rx,
|
||||
reader_hub,
|
||||
conn_id,
|
||||
conn.clone(),
|
||||
reader_tx,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Wait for reader to end, then cleanup writer/ping and unregister from hub.
|
||||
let _ = reader.await;
|
||||
ping_task.abort();
|
||||
writer.abort();
|
||||
hub.unregister_worker(conn_id);
|
||||
}
|
||||
|
||||
async fn run_worker_reader(
|
||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
||||
hub: Arc<HubRouter>,
|
||||
conn_id: u64,
|
||||
conn: Arc<WorkerConn>,
|
||||
tx: mpsc::UnboundedSender<Message>,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
loop {
|
||||
let msg = tokio::select! {
|
||||
msg = ws_rx.next() => msg,
|
||||
_ = tokio::time::sleep(idle_timeout) => {
|
||||
warn!(worker_id = conn_id, "worker idle timeout");
|
||||
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
let mut data = data.to_vec();
|
||||
if data.len() < protocol::HEADER_SIZE {
|
||||
debug!(worker_id = conn_id, "frame too small, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
let header = match protocol::FrameHeader::parse(&data) {
|
||||
Some(h) => h,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// HEARTBEAT_ACK from worker -> route back to proxy
|
||||
if header.msg_type == protocol::HEARTBEAT_ACK {
|
||||
hub.handle_worker_heartbeat_ack(&mut data);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular frames: route via hub
|
||||
if let Some(err_msg) = hub.handle_worker_frame(conn_id, &mut data) {
|
||||
// Send STREAM_ERROR back to worker
|
||||
let err_frame = protocol::encode_stream_error(header.stream_id, &err_msg);
|
||||
let _ = conn.send(Message::Binary(err_frame.into()));
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
info!(worker_id = conn_id, "worker WebSocket closed");
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!(worker_id = conn_id, error = %e, "worker WebSocket error");
|
||||
break;
|
||||
}
|
||||
_ => {} // Ignore text/ping/pong at WS level
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
# Aether server URL
|
||||
AETHER_PROXY_AETHER_URL=https://aether.example.com
|
||||
|
||||
# Management Token (ae_xxx, must belong to an ADMIN user)
|
||||
AETHER_PROXY_MANAGEMENT_TOKEN=ae_xxxxx
|
||||
|
||||
# Node identification
|
||||
AETHER_PROXY_NODE_NAME=proxy-01
|
||||
Generated
-3398
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
||||
[package]
|
||||
name = "aether-proxy"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
description = "Tunnel proxy for Aether"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "http2"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
bytes = "1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
anyhow = "1"
|
||||
arc-swap = "1"
|
||||
toml = "0.8"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.28"
|
||||
url = "2"
|
||||
sysinfo = "0.32"
|
||||
libc = "0.2"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
webpki-roots = "0.26"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY build/linux-${TARGETARCH}/aether-proxy /usr/local/bin/aether-proxy
|
||||
|
||||
ENTRYPOINT ["aether-proxy"]
|
||||
@@ -1,154 +0,0 @@
|
||||
# aether-proxy
|
||||
|
||||
Aether Tunnel 代理节点,部署在海外 VPS 上,通过 WebSocket 隧道为 Aether 实例中转 API 流量。
|
||||
|
||||
Tunnel 模式下代理节点**无需对外监听端口**,仅需出站连接到 Aether 服务器。
|
||||
|
||||
## 安装
|
||||
|
||||
### Docker Compose 部署
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入 AETHER_PROXY_AETHER_URL 和 AETHER_PROXY_MANAGEMENT_TOKEN
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 下载预编译二进制
|
||||
|
||||
<!-- DOWNLOAD_TABLE_START -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.2/aether-proxy-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.2/aether-proxy-linux-arm64.tar.gz) |
|
||||
| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.2/aether-proxy-macos-amd64.tar.gz) |
|
||||
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.2/aether-proxy-macos-arm64.tar.gz) |
|
||||
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.2/aether-proxy-windows-amd64.zip) |
|
||||
<!-- DOWNLOAD_TABLE_END -->
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 首次安装配置(TUI 向导,勾选 Install Service 随系统启动服务)
|
||||
sudo ./aether-proxy setup
|
||||
|
||||
# 2. 日常管理 (勾选 Install Service 作为系统服务的情况下)
|
||||
aether-proxy status # 看状态
|
||||
aether-proxy logs # 看日志
|
||||
|
||||
sudo aether-proxy start # 启动服务
|
||||
sudo aether-proxy stop # 停止服务
|
||||
sudo aether-proxy restart # 重启服务
|
||||
|
||||
# 3. 重新配置(改完自动重启服务)
|
||||
sudo aether-proxy setup
|
||||
|
||||
# 4. 彻底卸载
|
||||
sudo aether-proxy uninstall
|
||||
```
|
||||
|
||||
完成向导后, 配置自动保存到 `aether-proxy.toml`,如果启用了 Install Service,将自动注册并启动 systemd 服务。
|
||||
|
||||
### 直接运行
|
||||
|
||||
如果不需要安装为系统服务,可以直接运行。缺少必填参数时会自动进入 setup 向导:
|
||||
|
||||
```bash
|
||||
./aether-proxy
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
配置按以下优先级加载(高优先级覆盖低优先级):
|
||||
|
||||
1. CLI 参数
|
||||
2. 环境变量(`AETHER_PROXY_*`)
|
||||
3. 配置文件(`aether-proxy.toml`,或通过 `AETHER_PROXY_CONFIG` 指定路径)
|
||||
|
||||
### 参数一览
|
||||
|
||||
#### 基础配置
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--aether-url` | `AETHER_PROXY_AETHER_URL` | **必填** | Aether 服务器地址 |
|
||||
| `--management-token` | `AETHER_PROXY_MANAGEMENT_TOKEN` | **必填** | 管理员 Token(`ae_xxx` 格式) |
|
||||
| `--public-ip` | `AETHER_PROXY_PUBLIC_IP` | 自动检测 | 公网 IP |
|
||||
| `--node-name` | `AETHER_PROXY_NODE_NAME` | `proxy-01` | 节点名称标识 |
|
||||
| `--node-region` | `AETHER_PROXY_NODE_REGION` | 自动检测 | 地区标识 |
|
||||
| `--heartbeat-interval` | `AETHER_PROXY_HEARTBEAT_INTERVAL` | `30` | 心跳间隔(秒) |
|
||||
| `--allowed-ports` | `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 |
|
||||
|
||||
#### Tunnel 连接
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--tunnel-connections` | `AETHER_PROXY_TUNNEL_CONNECTIONS` | `3` | 到 Aether 的连接池大小 |
|
||||
| `--tunnel-max-streams` | `AETHER_PROXY_TUNNEL_MAX_STREAMS` | 自动(硬件估算) | 单连接最大并发 stream 数 |
|
||||
| `--tunnel-connect-timeout-secs` | `AETHER_PROXY_TUNNEL_CONNECT_TIMEOUT_SECS` | `15` | TCP + TLS 握手超时(秒) |
|
||||
| `--tunnel-tcp-keepalive-secs` | `AETHER_PROXY_TUNNEL_TCP_KEEPALIVE_SECS` | `30` | TCP keepalive 初始延迟(秒) |
|
||||
| `--tunnel-tcp-nodelay` | `AETHER_PROXY_TUNNEL_TCP_NODELAY` | `true` | 禁用 Nagle 算法 |
|
||||
| `--tunnel-ping-interval-secs` | `AETHER_PROXY_TUNNEL_PING_INTERVAL_SECS` | `15` | WebSocket Ping 频率(秒) |
|
||||
| `--tunnel-stale-timeout-secs` | `AETHER_PROXY_TUNNEL_STALE_TIMEOUT_SECS` | `45` | 无数据断连阈值(秒) |
|
||||
| `--tunnel-reconnect-base-ms` | `AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS` | `500` | 指数退避基础延迟(毫秒) |
|
||||
| `--tunnel-reconnect-max-ms` | `AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS` | `30000` | 指数退避上限(毫秒) |
|
||||
|
||||
#### 上游 HTTP 请求
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--upstream-connect-timeout-secs` | `AETHER_PROXY_UPSTREAM_CONNECT_TIMEOUT_SECS` | `30` | 上游建连超时(秒) |
|
||||
| `--upstream-pool-max-idle-per-host` | `AETHER_PROXY_UPSTREAM_POOL_MAX_IDLE_PER_HOST` | `64` | 每 Host 最大空闲连接数 |
|
||||
| `--upstream-pool-idle-timeout-secs` | `AETHER_PROXY_UPSTREAM_POOL_IDLE_TIMEOUT_SECS` | `300` | 连接池空闲超时(秒) |
|
||||
| `--upstream-tcp-keepalive-secs` | `AETHER_PROXY_UPSTREAM_TCP_KEEPALIVE_SECS` | `60` | TCP keepalive(秒,0 关闭) |
|
||||
| `--upstream-tcp-nodelay` | `AETHER_PROXY_UPSTREAM_TCP_NODELAY` | `true` | 启用 TCP_NODELAY |
|
||||
|
||||
#### Aether API 客户端
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--aether-request-timeout-secs` | `AETHER_PROXY_AETHER_REQUEST_TIMEOUT_SECS` | `10` | 请求总超时(秒) |
|
||||
| `--aether-connect-timeout-secs` | `AETHER_PROXY_AETHER_CONNECT_TIMEOUT_SECS` | `10` | 建连超时(秒) |
|
||||
| `--aether-retry-max-attempts` | `AETHER_PROXY_AETHER_RETRY_MAX_ATTEMPTS` | `3` | 最大重试次数 |
|
||||
|
||||
#### DNS 与安全
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--dns-cache-ttl-secs` | `AETHER_PROXY_DNS_CACHE_TTL_SECS` | `60` | DNS 缓存 TTL(秒) |
|
||||
| `--dns-cache-capacity` | `AETHER_PROXY_DNS_CACHE_CAPACITY` | `1024` | DNS 缓存容量(条目数) |
|
||||
|
||||
#### 日志
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--log-level` | `AETHER_PROXY_LOG_LEVEL` | `info` | 日志级别 |
|
||||
| `--log-json` | `AETHER_PROXY_LOG_JSON` | `false` | JSON 格式日志 |
|
||||
|
||||
### 多服务器配置
|
||||
|
||||
在 `aether-proxy.toml` 中使用 `[[servers]]` 配置多个 Aether 服务器:
|
||||
|
||||
```toml
|
||||
[[servers]]
|
||||
aether_url = "https://aether-1.example.com"
|
||||
management_token = "ae_xxx"
|
||||
node_name = "jp-proxy-01"
|
||||
|
||||
[[servers]]
|
||||
aether_url = "https://aether-2.example.com"
|
||||
management_token = "ae_yyy"
|
||||
node_name = "jp-proxy-02"
|
||||
```
|
||||
|
||||
## 发布新版本
|
||||
|
||||
推送 `proxy-v*` 格式的 tag,GitHub Actions 会自动:
|
||||
- 编译所有平台二进制并发布到 Releases
|
||||
- 构建 Docker 镜像并推送到 GHCR 和 Docker Hub
|
||||
- 更新 README 中的下载链接表格
|
||||
|
||||
```bash
|
||||
git tag proxy-v0.2.0
|
||||
git push origin proxy-v0.2.0
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
services:
|
||||
aether-proxy:
|
||||
image: ghcr.io/fawney19/aether-proxy:latest
|
||||
container_name: aether-proxy
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AETHER_PROXY_LOG_JSON: "true"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -1,377 +0,0 @@
|
||||
//! Application lifecycle: initialization, task orchestration, and shutdown.
|
||||
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use tokio::signal;
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::config::{Config, ServerEntry};
|
||||
use crate::net;
|
||||
use crate::registration::client::AetherClient;
|
||||
use crate::runtime::{self, DynamicConfig};
|
||||
use crate::safe_dns::SafeDnsResolver;
|
||||
use crate::state::{AppState, ProxyMetrics, ServerContext};
|
||||
use crate::{hardware, target_filter, tunnel};
|
||||
|
||||
/// Run the full application lifecycle after config has been parsed.
|
||||
pub async fn run(mut config: Config, servers: Vec<ServerEntry>) -> anyhow::Result<()> {
|
||||
config.validate()?;
|
||||
init_tracing(&config);
|
||||
|
||||
info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
node_name = %config.node_name,
|
||||
server_count = servers.len(),
|
||||
"aether-proxy starting (tunnel mode)"
|
||||
);
|
||||
|
||||
// Resolve public IP (best-effort for region info)
|
||||
let public_ip = match &config.public_ip {
|
||||
Some(ip) => ip.clone(),
|
||||
None => net::detect_public_ip()
|
||||
.await
|
||||
.unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
};
|
||||
|
||||
// Auto-detect region if not configured
|
||||
if config.node_region.is_none() {
|
||||
if let Some(region) = net::detect_region(&public_ip).await {
|
||||
config.node_region = Some(region);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect hardware info (once at startup, sent during registration)
|
||||
let hw_info = hardware::collect();
|
||||
|
||||
// Auto-detect tunnel_max_streams from hardware if not explicitly set
|
||||
if config.tunnel_max_streams.is_none() {
|
||||
let auto = (hw_info.estimated_max_concurrency / 10).clamp(64, 1024) as u32;
|
||||
config.tunnel_max_streams = Some(auto);
|
||||
info!(
|
||||
tunnel_max_streams = auto,
|
||||
"auto-detected tunnel_max_streams from hardware"
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
max_concurrency = hw_info.estimated_max_concurrency,
|
||||
"hardware info collected"
|
||||
);
|
||||
|
||||
let dns_cache = Arc::new(target_filter::DnsCache::new(
|
||||
Duration::from_secs(config.dns_cache_ttl_secs),
|
||||
config.dns_cache_capacity,
|
||||
));
|
||||
|
||||
// Build reqwest client for tunnel upstream requests (shared).
|
||||
// Inject SafeDnsResolver so reqwest only connects to addresses that were
|
||||
// validated by validate_target() — this eliminates the DNS rebinding
|
||||
// TOCTTOU gap where a second DNS lookup could return a private IP.
|
||||
let safe_resolver = SafeDnsResolver::new(Arc::clone(&dns_cache));
|
||||
let mut reqwest_builder = reqwest::Client::builder()
|
||||
.dns_resolver(Arc::new(safe_resolver))
|
||||
.pool_max_idle_per_host(config.upstream_pool_max_idle_per_host)
|
||||
.pool_idle_timeout(Duration::from_secs(config.upstream_pool_idle_timeout_secs))
|
||||
.connect_timeout(Duration::from_secs(config.upstream_connect_timeout_secs))
|
||||
.tcp_nodelay(config.upstream_tcp_nodelay);
|
||||
|
||||
if config.upstream_tcp_keepalive_secs > 0 {
|
||||
reqwest_builder = reqwest_builder.tcp_keepalive(Some(Duration::from_secs(
|
||||
config.upstream_tcp_keepalive_secs,
|
||||
)));
|
||||
}
|
||||
|
||||
let reqwest_client = reqwest_builder
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
|
||||
// Register with each Aether server and build per-server contexts.
|
||||
// Wrapped in Arc<Mutex> so retry_failed_registrations can append later.
|
||||
let server_contexts: Arc<Mutex<Vec<Arc<ServerContext>>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut failed_entries: Vec<(String, ServerEntry)> = Vec::new();
|
||||
for (i, entry) in servers.iter().enumerate() {
|
||||
let label = if servers.len() == 1 {
|
||||
"server".to_string()
|
||||
} else {
|
||||
format!("server-{}", i)
|
||||
};
|
||||
let node_name = entry
|
||||
.node_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.node_name.clone());
|
||||
let client = Arc::new(AetherClient::new(
|
||||
&config,
|
||||
&entry.aether_url,
|
||||
&entry.management_token,
|
||||
));
|
||||
match client
|
||||
.register(&config, &node_name, &public_ip, Some(&hw_info))
|
||||
.await
|
||||
{
|
||||
Ok(node_id) => {
|
||||
info!(server = %label, node_id = %node_id, url = %entry.aether_url, node_name = %node_name, "registered");
|
||||
// Initialize dynamic config with per-server node_name (not global),
|
||||
// so that the heartbeat and reconnect use the correct name.
|
||||
let mut dynamic = DynamicConfig::from_config(&config);
|
||||
dynamic.node_name = node_name.clone();
|
||||
server_contexts.lock().await.push(Arc::new(ServerContext {
|
||||
server_label: label,
|
||||
aether_url: entry.aether_url.clone(),
|
||||
management_token: entry.management_token.clone(),
|
||||
node_name,
|
||||
node_id: Arc::new(RwLock::new(node_id)),
|
||||
aether_client: client,
|
||||
dynamic: Arc::new(ArcSwap::from_pointee(dynamic)),
|
||||
active_connections: Arc::new(AtomicU64::new(0)),
|
||||
metrics: Arc::new(ProxyMetrics::new()),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
server = %label,
|
||||
url = %entry.aether_url,
|
||||
error = %e,
|
||||
"registration failed, will retry in background"
|
||||
);
|
||||
failed_entries.push((label, entry.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let ctx_count = server_contexts.lock().await.len();
|
||||
if ctx_count == 0 && failed_entries.is_empty() {
|
||||
anyhow::bail!("no servers configured");
|
||||
}
|
||||
if ctx_count == 0 {
|
||||
anyhow::bail!(
|
||||
"no servers registered successfully (all {} failed)",
|
||||
failed_entries.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build shared application state
|
||||
let tunnel_tls_config = Arc::new(crate::tunnel::client::build_tls_config());
|
||||
let state = Arc::new(AppState {
|
||||
config: Arc::new(config),
|
||||
dns_cache,
|
||||
reqwest_client,
|
||||
tunnel_tls_config,
|
||||
});
|
||||
|
||||
// Shutdown signal channel
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
info!(
|
||||
active_servers = server_contexts.lock().await.len(),
|
||||
"running in tunnel mode"
|
||||
);
|
||||
|
||||
// Spawn tunnel connections per server (pool_size connections each)
|
||||
let pool_size = state.config.tunnel_connections.max(1) as usize;
|
||||
let mut tunnel_handles = Vec::new();
|
||||
for server in server_contexts.lock().await.iter() {
|
||||
for conn_idx in 0..pool_size {
|
||||
let s = Arc::clone(&state);
|
||||
let srv = Arc::clone(server);
|
||||
let rx = shutdown_rx.clone();
|
||||
tunnel_handles.push(tokio::spawn(async move {
|
||||
tunnel::run(&s, &srv, conn_idx, rx).await;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn background retry for failed server registrations
|
||||
if !failed_entries.is_empty() {
|
||||
let retry_state = Arc::clone(&state);
|
||||
let retry_contexts = Arc::clone(&server_contexts);
|
||||
let retry_public_ip = public_ip.clone();
|
||||
let retry_hw_info = hw_info.clone();
|
||||
let retry_shutdown = shutdown_rx.clone();
|
||||
let retry_pool_size = pool_size;
|
||||
tokio::spawn(async move {
|
||||
retry_failed_registrations(
|
||||
retry_state,
|
||||
retry_contexts,
|
||||
failed_entries,
|
||||
retry_public_ip,
|
||||
retry_hw_info,
|
||||
retry_pool_size,
|
||||
retry_shutdown,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for shutdown signal
|
||||
wait_for_shutdown().await;
|
||||
info!("shutdown signal received, cleaning up...");
|
||||
let _ = shutdown_tx.send(true);
|
||||
|
||||
// Graceful unregister from all servers (including retry-registered ones)
|
||||
for server in server_contexts.lock().await.iter() {
|
||||
let node_id = server.node_id.read().unwrap().clone();
|
||||
if let Err(e) = server.aether_client.unregister(&node_id).await {
|
||||
error!(
|
||||
server = %server.server_label,
|
||||
error = %e,
|
||||
"unregister failed during shutdown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tunnel tasks
|
||||
for h in tunnel_handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
info!("aether-proxy stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retry interval for failed server registrations (5 minutes).
|
||||
const REGISTRATION_RETRY_INTERVAL: Duration = Duration::from_secs(300);
|
||||
/// Max registration retry attempts before giving up.
|
||||
const REGISTRATION_RETRY_MAX: u32 = 12;
|
||||
|
||||
/// Background task that retries registration for servers that failed at startup.
|
||||
async fn retry_failed_registrations(
|
||||
state: Arc<AppState>,
|
||||
server_contexts: Arc<Mutex<Vec<Arc<ServerContext>>>>,
|
||||
failed: Vec<(String, ServerEntry)>,
|
||||
public_ip: String,
|
||||
hw_info: crate::hardware::HardwareInfo,
|
||||
pool_size: usize,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
for (label, entry) in &failed {
|
||||
let node_name = entry
|
||||
.node_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.config.node_name.clone());
|
||||
let client = Arc::new(AetherClient::new(
|
||||
&state.config,
|
||||
&entry.aether_url,
|
||||
&entry.management_token,
|
||||
));
|
||||
|
||||
let mut attempt = 0u32;
|
||||
let node_id = loop {
|
||||
attempt += 1;
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(REGISTRATION_RETRY_INTERVAL) => {}
|
||||
_ = shutdown.changed() => {
|
||||
info!(server = %label, "shutdown during registration retry");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match client
|
||||
.register(&state.config, &node_name, &public_ip, Some(&hw_info))
|
||||
.await
|
||||
{
|
||||
Ok(id) => {
|
||||
info!(server = %label, node_id = %id, attempt, "registration retry succeeded");
|
||||
break id;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
server = %label,
|
||||
attempt,
|
||||
max = REGISTRATION_RETRY_MAX,
|
||||
error = %e,
|
||||
"registration retry failed"
|
||||
);
|
||||
if attempt >= REGISTRATION_RETRY_MAX {
|
||||
error!(server = %label, "giving up registration after {} attempts", attempt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build server context and spawn tunnels
|
||||
let mut dynamic = DynamicConfig::from_config(&state.config);
|
||||
dynamic.node_name = node_name.clone();
|
||||
let server = Arc::new(ServerContext {
|
||||
server_label: label.clone(),
|
||||
aether_url: entry.aether_url.clone(),
|
||||
management_token: entry.management_token.clone(),
|
||||
node_name,
|
||||
node_id: Arc::new(RwLock::new(node_id)),
|
||||
aether_client: client,
|
||||
dynamic: Arc::new(ArcSwap::from_pointee(dynamic)),
|
||||
active_connections: Arc::new(AtomicU64::new(0)),
|
||||
metrics: Arc::new(ProxyMetrics::new()),
|
||||
});
|
||||
|
||||
// Add to shared list so shutdown can unregister this server
|
||||
server_contexts.lock().await.push(Arc::clone(&server));
|
||||
|
||||
for conn_idx in 0..pool_size {
|
||||
let s = Arc::clone(&state);
|
||||
let srv = Arc::clone(&server);
|
||||
let rx = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
tunnel::run(&s, &srv, conn_idx, rx).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing(config: &Config) {
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{reload, EnvFilter};
|
||||
|
||||
let filter = EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
let (filter_layer, reload_handle) = reload::Layer::new(filter);
|
||||
|
||||
runtime::set_log_reloader(Box::new(move |level: &str| {
|
||||
if let Ok(new_filter) = EnvFilter::try_new(level) {
|
||||
let _ = reload_handle.modify(|f| *f = new_filter);
|
||||
}
|
||||
}));
|
||||
|
||||
if config.log_json {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(tracing_subscriber::fmt::layer().json())
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Fields that existed in 0.1.x but were removed in 0.2.0.
|
||||
const LEGACY_ONLY_KEYS: &[&str] = &[
|
||||
"hmac_key",
|
||||
"listen_port",
|
||||
"timestamp_tolerance",
|
||||
"connect_timeout_secs",
|
||||
"tls_handshake_timeout_secs",
|
||||
"enable_tls",
|
||||
"tls_cert",
|
||||
"tls_key",
|
||||
];
|
||||
|
||||
/// Fields renamed from 0.1.x `delegate_*` to 0.2.0 `upstream_*`.
|
||||
const DELEGATE_TO_UPSTREAM: &[(&str, &str)] = &[
|
||||
(
|
||||
"delegate_connect_timeout_secs",
|
||||
"upstream_connect_timeout_secs",
|
||||
),
|
||||
(
|
||||
"delegate_pool_max_idle_per_host",
|
||||
"upstream_pool_max_idle_per_host",
|
||||
),
|
||||
(
|
||||
"delegate_pool_idle_timeout_secs",
|
||||
"upstream_pool_idle_timeout_secs",
|
||||
),
|
||||
("delegate_tcp_keepalive_secs", "upstream_tcp_keepalive_secs"),
|
||||
("delegate_tcp_nodelay", "upstream_tcp_nodelay"),
|
||||
];
|
||||
|
||||
/// Aether tunnel proxy.
|
||||
///
|
||||
/// Deployed on overseas VPS to relay API traffic for Aether instances
|
||||
/// behind the GFW. Connects to Aether via WebSocket tunnel, registers
|
||||
/// with Aether, and relays upstream requests.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(version, about)]
|
||||
pub struct Config {
|
||||
/// Aether server URL (e.g. https://aether.example.com)
|
||||
#[arg(long, env = "AETHER_PROXY_AETHER_URL")]
|
||||
pub aether_url: String,
|
||||
|
||||
/// Management Token for Aether admin API (ae_xxx)
|
||||
#[arg(long, env = "AETHER_PROXY_MANAGEMENT_TOKEN")]
|
||||
pub management_token: String,
|
||||
|
||||
/// Public IP address of this node (auto-detected if omitted)
|
||||
#[arg(long, env = "AETHER_PROXY_PUBLIC_IP")]
|
||||
pub public_ip: Option<String>,
|
||||
|
||||
/// Human-readable node name
|
||||
#[arg(long, env = "AETHER_PROXY_NODE_NAME", default_value = "proxy-01")]
|
||||
pub node_name: String,
|
||||
|
||||
/// Region label (e.g. ap-northeast-1)
|
||||
#[arg(long, env = "AETHER_PROXY_NODE_REGION")]
|
||||
pub node_region: Option<String>,
|
||||
|
||||
/// Heartbeat interval in seconds
|
||||
#[arg(long, env = "AETHER_PROXY_HEARTBEAT_INTERVAL", default_value_t = 30)]
|
||||
pub heartbeat_interval: u64,
|
||||
|
||||
/// Allowed destination ports (default: 80,443,8080,8443)
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_ALLOWED_PORTS",
|
||||
value_delimiter = ',',
|
||||
default_values_t = vec![80, 443, 8080, 8443]
|
||||
)]
|
||||
pub allowed_ports: Vec<u16>,
|
||||
|
||||
/// Aether API request timeout in seconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_REQUEST_TIMEOUT",
|
||||
default_value_t = 10
|
||||
)]
|
||||
pub aether_request_timeout_secs: u64,
|
||||
|
||||
/// Aether API connect timeout in seconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_CONNECT_TIMEOUT",
|
||||
default_value_t = 10
|
||||
)]
|
||||
pub aether_connect_timeout_secs: u64,
|
||||
|
||||
/// Aether API max idle connections per host
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_POOL_MAX_IDLE_PER_HOST",
|
||||
default_value_t = 8
|
||||
)]
|
||||
pub aether_pool_max_idle_per_host: usize,
|
||||
|
||||
/// Aether API idle timeout in seconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_POOL_IDLE_TIMEOUT",
|
||||
default_value_t = 90
|
||||
)]
|
||||
pub aether_pool_idle_timeout_secs: u64,
|
||||
|
||||
/// Aether API TCP keepalive in seconds (0 disables)
|
||||
#[arg(long, env = "AETHER_PROXY_AETHER_TCP_KEEPALIVE", default_value_t = 60)]
|
||||
pub aether_tcp_keepalive_secs: u64,
|
||||
|
||||
/// Aether API TCP_NODELAY
|
||||
#[arg(long, env = "AETHER_PROXY_AETHER_TCP_NODELAY", default_value_t = true)]
|
||||
pub aether_tcp_nodelay: bool,
|
||||
|
||||
/// Enable HTTP/2 when talking to Aether API
|
||||
#[arg(long, env = "AETHER_PROXY_AETHER_HTTP2", default_value_t = true)]
|
||||
pub aether_http2: bool,
|
||||
|
||||
/// Aether API retry attempts (including initial)
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_RETRY_MAX_ATTEMPTS",
|
||||
default_value_t = 3
|
||||
)]
|
||||
pub aether_retry_max_attempts: u32,
|
||||
|
||||
/// Aether API retry base delay in milliseconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_RETRY_BASE_DELAY_MS",
|
||||
default_value_t = 200
|
||||
)]
|
||||
pub aether_retry_base_delay_ms: u64,
|
||||
|
||||
/// Aether API retry max delay in milliseconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_AETHER_RETRY_MAX_DELAY_MS",
|
||||
default_value_t = 2000
|
||||
)]
|
||||
pub aether_retry_max_delay_ms: u64,
|
||||
|
||||
/// Maximum concurrent TCP connections (defaults to hardware estimate)
|
||||
#[arg(long, env = "AETHER_PROXY_MAX_CONCURRENT_CONNECTIONS")]
|
||||
pub max_concurrent_connections: Option<u64>,
|
||||
|
||||
/// DNS cache TTL in seconds
|
||||
#[arg(long, env = "AETHER_PROXY_DNS_CACHE_TTL", default_value_t = 60)]
|
||||
pub dns_cache_ttl_secs: u64,
|
||||
|
||||
/// DNS cache capacity (entries)
|
||||
#[arg(long, env = "AETHER_PROXY_DNS_CACHE_CAPACITY", default_value_t = 1024)]
|
||||
pub dns_cache_capacity: usize,
|
||||
|
||||
/// Upstream HTTP client connect timeout in seconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_UPSTREAM_CONNECT_TIMEOUT",
|
||||
default_value_t = 30
|
||||
)]
|
||||
pub upstream_connect_timeout_secs: u64,
|
||||
|
||||
/// Upstream HTTP client max idle connections per host
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_UPSTREAM_POOL_MAX_IDLE_PER_HOST",
|
||||
default_value_t = 64
|
||||
)]
|
||||
pub upstream_pool_max_idle_per_host: usize,
|
||||
|
||||
/// Upstream HTTP client idle timeout in seconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_UPSTREAM_POOL_IDLE_TIMEOUT",
|
||||
default_value_t = 300
|
||||
)]
|
||||
pub upstream_pool_idle_timeout_secs: u64,
|
||||
|
||||
/// Upstream TCP keepalive in seconds (0 disables)
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_UPSTREAM_TCP_KEEPALIVE",
|
||||
default_value_t = 60
|
||||
)]
|
||||
pub upstream_tcp_keepalive_secs: u64,
|
||||
|
||||
/// Upstream TCP_NODELAY
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_UPSTREAM_TCP_NODELAY",
|
||||
default_value_t = true
|
||||
)]
|
||||
pub upstream_tcp_nodelay: bool,
|
||||
|
||||
/// Log level (trace, debug, info, warn, error)
|
||||
#[arg(long, env = "AETHER_PROXY_LOG_LEVEL", default_value = "info")]
|
||||
pub log_level: String,
|
||||
|
||||
/// Output logs as JSON
|
||||
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
|
||||
pub log_json: bool,
|
||||
|
||||
/// Tunnel reconnect base delay in milliseconds (used by exponential backoff)
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS",
|
||||
default_value_t = 500
|
||||
)]
|
||||
pub tunnel_reconnect_base_ms: u64,
|
||||
|
||||
/// Tunnel reconnect max delay in milliseconds (cap for exponential backoff)
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS",
|
||||
default_value_t = 30000
|
||||
)]
|
||||
pub tunnel_reconnect_max_ms: u64,
|
||||
|
||||
/// WebSocket tunnel ping interval in seconds
|
||||
#[arg(long, env = "AETHER_PROXY_TUNNEL_PING_INTERVAL", default_value_t = 15)]
|
||||
pub tunnel_ping_interval_secs: u64,
|
||||
|
||||
/// Maximum concurrent streams over tunnel (auto-detected from hardware if omitted)
|
||||
#[arg(long, env = "AETHER_PROXY_TUNNEL_MAX_STREAMS")]
|
||||
pub tunnel_max_streams: Option<u32>,
|
||||
|
||||
/// WebSocket tunnel TCP connect timeout in seconds
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_PROXY_TUNNEL_CONNECT_TIMEOUT",
|
||||
default_value_t = 15
|
||||
)]
|
||||
pub tunnel_connect_timeout_secs: u64,
|
||||
|
||||
/// WebSocket tunnel TCP keepalive in seconds (0 disables)
|
||||
#[arg(long, env = "AETHER_PROXY_TUNNEL_TCP_KEEPALIVE", default_value_t = 30)]
|
||||
pub tunnel_tcp_keepalive_secs: u64,
|
||||
|
||||
/// WebSocket tunnel TCP_NODELAY
|
||||
#[arg(long, env = "AETHER_PROXY_TUNNEL_TCP_NODELAY", default_value_t = true)]
|
||||
pub tunnel_tcp_nodelay: bool,
|
||||
|
||||
/// Tunnel connection staleness timeout in seconds (triggers reconnect if no data received)
|
||||
#[arg(long, env = "AETHER_PROXY_TUNNEL_STALE_TIMEOUT", default_value_t = 45)]
|
||||
pub tunnel_stale_timeout_secs: u64,
|
||||
|
||||
/// Number of parallel WebSocket tunnel connections per server (connection pool)
|
||||
#[arg(long, env = "AETHER_PROXY_TUNNEL_CONNECTIONS", default_value_t = 3)]
|
||||
pub tunnel_connections: u32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Validate configuration values are within sane ranges.
|
||||
/// Called after parsing to catch misconfigurations early.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.heartbeat_interval == 0 {
|
||||
anyhow::bail!("heartbeat_interval must be > 0");
|
||||
}
|
||||
if self.heartbeat_interval > 3600 {
|
||||
anyhow::bail!("heartbeat_interval must be <= 3600");
|
||||
}
|
||||
if self.allowed_ports.is_empty() {
|
||||
anyhow::bail!("allowed_ports must not be empty");
|
||||
}
|
||||
for &port in &self.allowed_ports {
|
||||
if port == 0 {
|
||||
anyhow::bail!("allowed_ports: port 0 is not valid");
|
||||
}
|
||||
}
|
||||
if self.tunnel_connect_timeout_secs == 0 {
|
||||
anyhow::bail!("tunnel_connect_timeout_secs must be > 0");
|
||||
}
|
||||
if self.tunnel_ping_interval_secs == 0 {
|
||||
anyhow::bail!("tunnel_ping_interval_secs must be > 0");
|
||||
}
|
||||
if self.tunnel_stale_timeout_secs <= self.tunnel_ping_interval_secs {
|
||||
anyhow::bail!(
|
||||
"tunnel_stale_timeout_secs ({}) must be > tunnel_ping_interval_secs ({})",
|
||||
self.tunnel_stale_timeout_secs,
|
||||
self.tunnel_ping_interval_secs
|
||||
);
|
||||
}
|
||||
if self.tunnel_connections == 0 {
|
||||
anyhow::bail!("tunnel_connections must be > 0");
|
||||
}
|
||||
if self.aether_retry_max_attempts == 0 {
|
||||
anyhow::bail!("aether_retry_max_attempts must be >= 1");
|
||||
}
|
||||
if self.upstream_connect_timeout_secs == 0 {
|
||||
anyhow::bail!("upstream_connect_timeout_secs must be > 0");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-server connection config (used in multi-server TOML `[[servers]]`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerEntry {
|
||||
pub aether_url: String,
|
||||
pub management_token: String,
|
||||
/// Per-server node name override. Falls back to the global `node_name`.
|
||||
pub node_name: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOML config file support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serializable config for TOML file persistence.
|
||||
/// All fields are optional -- only populated values are written.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub management_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_ip: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heartbeat_interval: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_ports: Option<Vec<u16>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_request_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_connect_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_pool_max_idle_per_host: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_pool_idle_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_tcp_keepalive_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_tcp_nodelay: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_http2: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_retry_max_attempts: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_retry_base_delay_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aether_retry_max_delay_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrent_connections: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dns_cache_ttl_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dns_cache_capacity: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_connect_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_pool_max_idle_per_host: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_pool_idle_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_tcp_keepalive_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_tcp_nodelay: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub log_level: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub log_json: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_reconnect_base_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_reconnect_max_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_ping_interval_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_max_streams: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_connect_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_tcp_keepalive_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_tcp_nodelay: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_stale_timeout_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_connections: Option<u32>,
|
||||
|
||||
/// Multi-server config: each entry connects to a separate Aether instance.
|
||||
/// When present, top-level aether_url/management_token are ignored for
|
||||
/// tunnel connections (but still injected as env for clap compatibility).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub servers: Vec<ServerEntry>,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
/// Load from a TOML file.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
}
|
||||
|
||||
/// Save to a TOML file.
|
||||
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect and migrate a 0.1.x config file to 0.2.0 format in-place.
|
||||
///
|
||||
/// Returns `true` if migration was performed, `false` if already current.
|
||||
/// The original file is backed up as `<name>.v1.bak` before rewriting.
|
||||
pub fn migrate_legacy(path: &Path) -> anyhow::Result<bool> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
let mut table: toml::map::Map<String, toml::Value> = toml::from_str(&content)?;
|
||||
|
||||
// Detect legacy format: presence of any 0.1.x-only key.
|
||||
let is_legacy = LEGACY_ONLY_KEYS.iter().any(|k| table.contains_key(*k))
|
||||
|| DELEGATE_TO_UPSTREAM
|
||||
.iter()
|
||||
.any(|(old, _)| table.contains_key(*old));
|
||||
|
||||
if !is_legacy {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 1. Rename delegate_* -> upstream_* (carry over user-customized values)
|
||||
for &(old, new) in DELEGATE_TO_UPSTREAM {
|
||||
if let Some(val) = table.remove(old) {
|
||||
table.entry(new.to_string()).or_insert(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build [[servers]] from top-level aether_url + management_token + node_name
|
||||
if !table.contains_key("servers") {
|
||||
let aether_url = table.get("aether_url").and_then(|v| v.as_str());
|
||||
let management_token = table.get("management_token").and_then(|v| v.as_str());
|
||||
if let (Some(url), Some(token)) = (aether_url, management_token) {
|
||||
let mut entry = toml::map::Map::new();
|
||||
entry.insert("aether_url".into(), toml::Value::String(url.to_string()));
|
||||
entry.insert(
|
||||
"management_token".into(),
|
||||
toml::Value::String(token.to_string()),
|
||||
);
|
||||
if let Some(name) = table.get("node_name").and_then(|v| v.as_str()) {
|
||||
entry.insert("node_name".into(), toml::Value::String(name.to_string()));
|
||||
}
|
||||
table.insert(
|
||||
"servers".into(),
|
||||
toml::Value::Array(vec![toml::Value::Table(entry)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove top-level fields that are now in [[servers]] or obsolete
|
||||
table.remove("aether_url");
|
||||
table.remove("management_token");
|
||||
table.remove("node_name");
|
||||
for &key in LEGACY_ONLY_KEYS {
|
||||
table.remove(key);
|
||||
}
|
||||
|
||||
// 4. Backup original file (abort migration if backup fails)
|
||||
let backup_path = path.with_extension("v1.bak");
|
||||
std::fs::copy(path, &backup_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to backup config before migration: {} -> {}: {}",
|
||||
path.display(),
|
||||
backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. Write migrated config
|
||||
let new_content = toml::to_string_pretty(&table)?;
|
||||
std::fs::write(path, &new_content)?;
|
||||
|
||||
eprintln!(" Config migrated from 0.1.x to 0.2.0 format.");
|
||||
eprintln!(" Backup saved: {}", backup_path.display());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Resolve the effective server list.
|
||||
///
|
||||
/// If `[[servers]]` is present, use it. Otherwise fall back to the
|
||||
/// top-level `aether_url` + `management_token` as a single server.
|
||||
pub fn effective_servers(&self) -> Vec<ServerEntry> {
|
||||
if !self.servers.is_empty() {
|
||||
return self.servers.clone();
|
||||
}
|
||||
match (&self.aether_url, &self.management_token) {
|
||||
(Some(url), Some(token)) => vec![ServerEntry {
|
||||
aether_url: url.clone(),
|
||||
management_token: token.clone(),
|
||||
node_name: None,
|
||||
}],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject values as environment variables so clap picks them up.
|
||||
///
|
||||
/// Only sets variables that are **not** already present in the
|
||||
/// environment, preserving the precedence: CLI > env > config file.
|
||||
pub fn inject_env(&self) {
|
||||
self.inject_env_inner(false);
|
||||
}
|
||||
|
||||
/// Inject values as environment variables, **overriding** any existing
|
||||
/// values. Used after setup to ensure the freshly-saved config takes
|
||||
/// effect before re-parsing.
|
||||
pub fn inject_env_override(&self) {
|
||||
self.inject_env_inner(true);
|
||||
}
|
||||
|
||||
fn inject_env_inner(&self, force: bool) {
|
||||
macro_rules! set {
|
||||
($env:expr, $val:expr) => {
|
||||
if let Some(ref v) = $val {
|
||||
if force || std::env::var($env).is_err() {
|
||||
std::env::set_var($env, v.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// When top-level fields are absent, fall back to the first [[servers]]
|
||||
// entry so that clap's required `aether_url` / `management_token` are
|
||||
// satisfied even with the new config format.
|
||||
let first_server = self.servers.first();
|
||||
let aether_url = self
|
||||
.aether_url
|
||||
.as_deref()
|
||||
.or(first_server.map(|s| s.aether_url.as_str()));
|
||||
let management_token = self
|
||||
.management_token
|
||||
.as_deref()
|
||||
.or(first_server.map(|s| s.management_token.as_str()));
|
||||
let node_name = self
|
||||
.node_name
|
||||
.as_deref()
|
||||
.or(first_server.and_then(|s| s.node_name.as_deref()));
|
||||
|
||||
set!("AETHER_PROXY_AETHER_URL", aether_url);
|
||||
set!("AETHER_PROXY_MANAGEMENT_TOKEN", management_token);
|
||||
set!("AETHER_PROXY_PUBLIC_IP", self.public_ip);
|
||||
set!("AETHER_PROXY_NODE_NAME", node_name);
|
||||
set!("AETHER_PROXY_NODE_REGION", self.node_region);
|
||||
set!("AETHER_PROXY_HEARTBEAT_INTERVAL", self.heartbeat_interval);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_REQUEST_TIMEOUT",
|
||||
self.aether_request_timeout_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_CONNECT_TIMEOUT",
|
||||
self.aether_connect_timeout_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_POOL_MAX_IDLE_PER_HOST",
|
||||
self.aether_pool_max_idle_per_host
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_POOL_IDLE_TIMEOUT",
|
||||
self.aether_pool_idle_timeout_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_TCP_KEEPALIVE",
|
||||
self.aether_tcp_keepalive_secs
|
||||
);
|
||||
set!("AETHER_PROXY_AETHER_TCP_NODELAY", self.aether_tcp_nodelay);
|
||||
set!("AETHER_PROXY_AETHER_HTTP2", self.aether_http2);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_RETRY_MAX_ATTEMPTS",
|
||||
self.aether_retry_max_attempts
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_RETRY_BASE_DELAY_MS",
|
||||
self.aether_retry_base_delay_ms
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_AETHER_RETRY_MAX_DELAY_MS",
|
||||
self.aether_retry_max_delay_ms
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_MAX_CONCURRENT_CONNECTIONS",
|
||||
self.max_concurrent_connections
|
||||
);
|
||||
set!("AETHER_PROXY_DNS_CACHE_TTL", self.dns_cache_ttl_secs);
|
||||
set!("AETHER_PROXY_DNS_CACHE_CAPACITY", self.dns_cache_capacity);
|
||||
set!(
|
||||
"AETHER_PROXY_UPSTREAM_CONNECT_TIMEOUT",
|
||||
self.upstream_connect_timeout_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_UPSTREAM_POOL_MAX_IDLE_PER_HOST",
|
||||
self.upstream_pool_max_idle_per_host
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_UPSTREAM_POOL_IDLE_TIMEOUT",
|
||||
self.upstream_pool_idle_timeout_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_UPSTREAM_TCP_KEEPALIVE",
|
||||
self.upstream_tcp_keepalive_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_UPSTREAM_TCP_NODELAY",
|
||||
self.upstream_tcp_nodelay
|
||||
);
|
||||
set!("AETHER_PROXY_LOG_LEVEL", self.log_level);
|
||||
set!("AETHER_PROXY_LOG_JSON", self.log_json);
|
||||
set!(
|
||||
"AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS",
|
||||
self.tunnel_reconnect_base_ms
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS",
|
||||
self.tunnel_reconnect_max_ms
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_TUNNEL_PING_INTERVAL",
|
||||
self.tunnel_ping_interval_secs
|
||||
);
|
||||
set!("AETHER_PROXY_TUNNEL_MAX_STREAMS", self.tunnel_max_streams);
|
||||
set!(
|
||||
"AETHER_PROXY_TUNNEL_CONNECT_TIMEOUT",
|
||||
self.tunnel_connect_timeout_secs
|
||||
);
|
||||
set!(
|
||||
"AETHER_PROXY_TUNNEL_TCP_KEEPALIVE",
|
||||
self.tunnel_tcp_keepalive_secs
|
||||
);
|
||||
set!("AETHER_PROXY_TUNNEL_TCP_NODELAY", self.tunnel_tcp_nodelay);
|
||||
set!(
|
||||
"AETHER_PROXY_TUNNEL_STALE_TIMEOUT",
|
||||
self.tunnel_stale_timeout_secs
|
||||
);
|
||||
set!("AETHER_PROXY_TUNNEL_CONNECTIONS", self.tunnel_connections);
|
||||
|
||||
// allowed_ports needs special handling (comma-separated)
|
||||
if let Some(ref ports) = self.allowed_ports {
|
||||
if force || std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() {
|
||||
let s: String = ports
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
std::env::set_var("AETHER_PROXY_ALLOWED_PORTS", s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use sysinfo::System;
|
||||
use tracing::info;
|
||||
|
||||
/// Hardware information collected at startup.
|
||||
///
|
||||
/// The struct is `Serialize`-able so it can be sent directly as the
|
||||
/// `hardware_info` JSON bag in the registration request. New fields
|
||||
/// can be added without database schema migrations.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HardwareInfo {
|
||||
pub cpu_cores: u32,
|
||||
pub total_memory_mb: u64,
|
||||
pub os_info: String,
|
||||
pub fd_limit: u64,
|
||||
#[serde(skip)]
|
||||
pub estimated_max_concurrency: u64,
|
||||
}
|
||||
|
||||
/// Collect hardware information and estimate max concurrency.
|
||||
///
|
||||
/// Should be called once at startup -- hardware does not change at runtime.
|
||||
pub fn collect() -> HardwareInfo {
|
||||
let sys = System::new_all();
|
||||
|
||||
let cpu_cores = sys.cpus().len() as u32;
|
||||
let total_memory_mb = sys.total_memory() / (1024 * 1024);
|
||||
let os_info = format!(
|
||||
"{} {}",
|
||||
System::name().unwrap_or_else(|| "Unknown".into()),
|
||||
System::os_version().unwrap_or_default(),
|
||||
)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Estimate max concurrent connections:
|
||||
// - Each tokio async task uses ~8-16 KB stack + heap buffers
|
||||
// - OS file descriptor limit is often the real bottleneck
|
||||
// - Conservative formula: min(fd_limit - 100, ram_mb * 40, cpu_cores * 2000)
|
||||
let fd_limit = get_fd_limit();
|
||||
let by_fd = fd_limit.saturating_sub(100);
|
||||
let by_ram = total_memory_mb.saturating_mul(40);
|
||||
let by_cpu = (cpu_cores as u64).saturating_mul(2000);
|
||||
let estimated_max_concurrency = by_fd.min(by_ram).min(by_cpu);
|
||||
|
||||
info!(
|
||||
cpu_cores,
|
||||
total_memory_mb,
|
||||
os_info = %os_info,
|
||||
fd_limit,
|
||||
estimated_max_concurrency,
|
||||
"hardware info collected"
|
||||
);
|
||||
|
||||
HardwareInfo {
|
||||
cpu_cores,
|
||||
total_memory_mb,
|
||||
os_info,
|
||||
fd_limit,
|
||||
estimated_max_concurrency,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the soft file-descriptor limit (RLIMIT_NOFILE).
|
||||
fn get_fd_limit() -> u64 {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut rlim = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
let ret = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) };
|
||||
if ret == 0 {
|
||||
return rlim.rlim_cur;
|
||||
}
|
||||
}
|
||||
// Fallback for non-unix or error
|
||||
1024
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod hardware;
|
||||
mod net;
|
||||
mod registration;
|
||||
mod runtime;
|
||||
mod safe_dns;
|
||||
mod setup;
|
||||
mod state;
|
||||
mod target_filter;
|
||||
mod tunnel;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
|
||||
use config::Config;
|
||||
|
||||
/// Default config file name.
|
||||
const DEFAULT_CONFIG: &str = "aether-proxy.toml";
|
||||
|
||||
/// Build the full clap command: Config args + discoverable subcommands.
|
||||
///
|
||||
/// `subcommand_negates_reqs` lets subcommands bypass the required Config
|
||||
/// flags so that e.g. `aether-proxy setup` doesn't demand `--aether-url`.
|
||||
fn build_command() -> clap::Command {
|
||||
Config::command()
|
||||
.subcommand(
|
||||
clap::Command::new("setup")
|
||||
.about("Interactive setup wizard (TUI)")
|
||||
.arg(
|
||||
clap::Arg::new("config_path")
|
||||
.help("Path to config file")
|
||||
.default_value(DEFAULT_CONFIG),
|
||||
),
|
||||
)
|
||||
.subcommand(clap::Command::new("start").about("Start the systemd service"))
|
||||
.subcommand(clap::Command::new("status").about("Show service status"))
|
||||
.subcommand(clap::Command::new("logs").about("Tail service logs"))
|
||||
.subcommand(clap::Command::new("restart").about("Restart the systemd service"))
|
||||
.subcommand(clap::Command::new("stop").about("Stop the systemd service"))
|
||||
.subcommand(clap::Command::new("uninstall").about("Uninstall the systemd service"))
|
||||
.subcommand(
|
||||
clap::Command::new("upgrade")
|
||||
.about("Self-upgrade from GitHub releases")
|
||||
.arg(clap::Arg::new("version").help("Target version (e.g. 0.2.0)")),
|
||||
)
|
||||
.subcommand_negates_reqs(true)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to install rustls CryptoProvider"))?;
|
||||
|
||||
// Load config file as env-var defaults (before clap parsing)
|
||||
let config_file_path =
|
||||
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
|
||||
let config_path = std::path::Path::new(&config_file_path);
|
||||
if config_path.exists() {
|
||||
// Migrate legacy 0.1.x config to 0.2.0 format if needed
|
||||
if let Err(e) = config::ConfigFile::migrate_legacy(config_path) {
|
||||
eprintln!(" WARNING: config migration failed: {}", e);
|
||||
}
|
||||
if let Ok(file_cfg) = config::ConfigFile::load(config_path) {
|
||||
file_cfg.inject_env();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI (subcommands + config args in one pass)
|
||||
match build_command().try_get_matches() {
|
||||
Ok(matches) => match matches.subcommand() {
|
||||
Some(("setup", sub_m)) => {
|
||||
let path = sub_m
|
||||
.get_one::<String>("config_path")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
|
||||
handle_setup_result(setup::run(path)?).await
|
||||
}
|
||||
Some(("start", _)) => setup::service::cmd_start(),
|
||||
Some(("status", _)) => setup::service::cmd_status(),
|
||||
Some(("logs", _)) => setup::service::cmd_logs(),
|
||||
Some(("restart", _)) => setup::service::cmd_restart(),
|
||||
Some(("stop", _)) => setup::service::cmd_stop(),
|
||||
Some(("uninstall", _)) => setup::service::cmd_uninstall(),
|
||||
Some(("upgrade", sub_m)) => {
|
||||
let version = sub_m.get_one::<String>("version").cloned();
|
||||
setup::upgrade::cmd_upgrade(version).await
|
||||
}
|
||||
Some(_) => unreachable!(),
|
||||
None => {
|
||||
// No subcommand — run the proxy with parsed config.
|
||||
let config = Config::from_arg_matches(&matches)?;
|
||||
run_proxy(config).await
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
|
||||
eprintln!("Missing required config, launching setup wizard...\n");
|
||||
handle_setup_result(setup::run(PathBuf::from(&config_file_path))?).await
|
||||
} else {
|
||||
e.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide what to do after the setup wizard completes.
|
||||
async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()> {
|
||||
match outcome {
|
||||
setup::SetupOutcome::ServiceInstalled => Ok(()),
|
||||
setup::SetupOutcome::ReadyToRun(config_path) => {
|
||||
// Reload config from the file that setup just wrote, overriding
|
||||
// any stale env vars from a previous config.
|
||||
match config::ConfigFile::load(&config_path) {
|
||||
Ok(file_cfg) => file_cfg.inject_env_override(),
|
||||
Err(e) => anyhow::bail!("failed to reload config after setup: {}", e),
|
||||
}
|
||||
// Parse from env-only (argv may still contain "setup" etc.)
|
||||
let config = Config::try_parse_from(["aether-proxy"])
|
||||
.map_err(|e| anyhow::anyhow!("config invalid after setup: {}", e))?;
|
||||
eprintln!(" Starting proxy...\n");
|
||||
run_proxy(config).await
|
||||
}
|
||||
setup::SetupOutcome::Cancelled => {
|
||||
eprintln!(" Setup cancelled.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the proxy server, checking for systemd conflicts first.
|
||||
async fn run_proxy(config: Config) -> anyhow::Result<()> {
|
||||
// Warn if systemd service is already running (would cause port conflict).
|
||||
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd).
|
||||
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() {
|
||||
eprintln!("Warning: systemd service is already running.");
|
||||
eprintln!("Use `./aether-proxy stop` to stop it first, or manage via subcommands:");
|
||||
eprintln!(" ./aether-proxy status / logs / restart / stop");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Resolve server list: prefer [[servers]] from TOML, fall back to CLI/env single server.
|
||||
let config_path =
|
||||
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
|
||||
let servers = if std::path::Path::new(&config_path).exists() {
|
||||
config::ConfigFile::load(std::path::Path::new(&config_path))
|
||||
.ok()
|
||||
.map(|f| f.effective_servers())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
vec![config::ServerEntry {
|
||||
aether_url: config.aether_url.clone(),
|
||||
management_token: config.management_token.clone(),
|
||||
node_name: None,
|
||||
}]
|
||||
})
|
||||
} else {
|
||||
vec![config::ServerEntry {
|
||||
aether_url: config.aether_url.clone(),
|
||||
management_token: config.management_token.clone(),
|
||||
node_name: None,
|
||||
}]
|
||||
};
|
||||
|
||||
app::run(config, servers).await
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
//! Network utility functions (public IP detection, region detection).
|
||||
//!
|
||||
//! These are standalone helpers not tied to any specific client or service.
|
||||
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Auto-detect public IP by querying external services.
|
||||
pub async fn detect_public_ip() -> anyhow::Result<String> {
|
||||
let endpoints = [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
];
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()?;
|
||||
|
||||
for endpoint in &endpoints {
|
||||
match client.get(*endpoint).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let ip = resp.text().await?.trim().to_string();
|
||||
if !ip.is_empty() {
|
||||
info!(ip = %ip, source = %endpoint, "detected public IP");
|
||||
return Ok(ip);
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
debug!(endpoint = %endpoint, status = %resp.status(), "IP detection failed");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(endpoint = %endpoint, error = %e, "IP detection failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("failed to detect public IP from any source; use --public-ip")
|
||||
}
|
||||
|
||||
/// Auto-detect geographic region from a public IP address.
|
||||
///
|
||||
/// Uses multiple providers with HTTPS preferred. Falls back to ip-api.com
|
||||
/// over plain HTTP (their free tier doesn't support HTTPS).
|
||||
/// This is best-effort and non-sensitive -- region detection should never
|
||||
/// block startup.
|
||||
pub async fn detect_region(ip: &str) -> Option<String> {
|
||||
// Try HTTPS provider first
|
||||
let https_url = format!("https://ipinfo.io/{}/country", ip);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
// Try ipinfo.io (HTTPS, returns plain text country code)
|
||||
if let Ok(resp) = client.get(&https_url).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(text) = resp.text().await {
|
||||
let code = text.trim();
|
||||
if !code.is_empty() && code.len() <= 3 {
|
||||
info!(region = %code, ip = %ip, source = "ipinfo.io", "detected region");
|
||||
return Some(code.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ip-api.com (HTTP only on free tier, non-sensitive data)
|
||||
let http_url = format!("http://ip-api.com/json/{}?fields=countryCode", ip);
|
||||
match client.get(&http_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
let code = body.get("countryCode")?.as_str()?;
|
||||
if code.is_empty() {
|
||||
return None;
|
||||
}
|
||||
info!(region = %code, ip = %ip, source = "ip-api.com", "detected region");
|
||||
Some(code.to_string())
|
||||
}
|
||||
_ => {
|
||||
debug!(ip = %ip, "region detection failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::hardware::HardwareInfo;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RegisterRequest {
|
||||
name: String,
|
||||
ip: String,
|
||||
port: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
region: Option<String>,
|
||||
heartbeat_interval: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hardware_info: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
estimated_max_concurrency: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
proxy_metadata: Option<serde_json::Value>,
|
||||
tunnel_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
/// Remote configuration pushed by the Aether management backend.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RemoteConfig {
|
||||
pub node_name: Option<String>,
|
||||
pub allowed_ports: Option<Vec<u16>>,
|
||||
pub log_level: Option<String>,
|
||||
pub heartbeat_interval: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UnregisterRequest {
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
/// Aether API client for proxy node lifecycle management.
|
||||
pub struct AetherClient {
|
||||
http: Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
retry_max_attempts: u32,
|
||||
retry_base_delay: Duration,
|
||||
retry_max_delay: Duration,
|
||||
}
|
||||
|
||||
impl AetherClient {
|
||||
pub fn new(config: &Config, aether_url: &str, management_token: &str) -> Self {
|
||||
let mut builder = Client::builder()
|
||||
.timeout(Duration::from_secs(config.aether_request_timeout_secs))
|
||||
.connect_timeout(Duration::from_secs(config.aether_connect_timeout_secs))
|
||||
.pool_max_idle_per_host(config.aether_pool_max_idle_per_host)
|
||||
.pool_idle_timeout(Duration::from_secs(config.aether_pool_idle_timeout_secs))
|
||||
.tcp_nodelay(config.aether_tcp_nodelay);
|
||||
|
||||
if config.aether_tcp_keepalive_secs > 0 {
|
||||
builder =
|
||||
builder.tcp_keepalive(Some(Duration::from_secs(config.aether_tcp_keepalive_secs)));
|
||||
} else {
|
||||
builder = builder.tcp_keepalive(None);
|
||||
}
|
||||
|
||||
if config.aether_http2 {
|
||||
builder = builder.http2_adaptive_window(true);
|
||||
}
|
||||
|
||||
let http = builder.build().expect("failed to create HTTP client");
|
||||
|
||||
let retry_base_delay = Duration::from_millis(config.aether_retry_base_delay_ms);
|
||||
let retry_max_delay =
|
||||
Duration::from_millis(config.aether_retry_max_delay_ms).max(retry_base_delay);
|
||||
|
||||
Self {
|
||||
http,
|
||||
base_url: aether_url.trim_end_matches('/').to_string(),
|
||||
token: management_token.to_string(),
|
||||
retry_max_attempts: config.aether_retry_max_attempts.max(1),
|
||||
retry_base_delay,
|
||||
retry_max_delay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node with Aether (idempotent upsert by ip:port).
|
||||
///
|
||||
/// Returns the stable node_id assigned by Aether.
|
||||
pub async fn register(
|
||||
&self,
|
||||
config: &Config,
|
||||
node_name: &str,
|
||||
public_ip: &str,
|
||||
hw: Option<&HardwareInfo>,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
|
||||
let body = RegisterRequest {
|
||||
name: node_name.to_string(),
|
||||
ip: public_ip.to_string(),
|
||||
port: 0,
|
||||
region: config.node_region.clone(),
|
||||
heartbeat_interval: config.heartbeat_interval,
|
||||
hardware_info: hw.and_then(|h| serde_json::to_value(h).ok()),
|
||||
estimated_max_concurrency: hw.map(|h| h.estimated_max_concurrency),
|
||||
proxy_metadata: Some(serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
tunnel_mode: true,
|
||||
};
|
||||
|
||||
info!(
|
||||
url = %url,
|
||||
name = %body.name,
|
||||
ip = %body.ip,
|
||||
"registering with Aether"
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.send_with_retry(
|
||||
|| {
|
||||
self.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&body)
|
||||
},
|
||||
"register",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("register failed (HTTP {}): {}", status, text);
|
||||
}
|
||||
|
||||
let data: RegisterResponse = resp.json().await?;
|
||||
info!(node_id = %data.node_id, "registered successfully");
|
||||
Ok(data.node_id)
|
||||
}
|
||||
|
||||
/// Unregister this node from Aether (graceful shutdown).
|
||||
pub async fn unregister(&self, node_id: &str) -> anyhow::Result<()> {
|
||||
let url = format!("{}/api/admin/proxy-nodes/unregister", self.base_url);
|
||||
let body = UnregisterRequest {
|
||||
node_id: node_id.to_string(),
|
||||
};
|
||||
|
||||
info!(node_id = %node_id, "unregistering from Aether");
|
||||
|
||||
let resp = self
|
||||
.send_with_retry(
|
||||
|| {
|
||||
self.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&body)
|
||||
},
|
||||
"unregister",
|
||||
)
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
info!(node_id = %node_id, "unregistered successfully");
|
||||
Ok(())
|
||||
}
|
||||
Ok(r) => {
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
error!(body = %text, "unregister failed");
|
||||
anyhow::bail!("unregister failed: {}", text);
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort during shutdown
|
||||
error!(error = %e, "unregister request failed");
|
||||
anyhow::bail!("unregister request failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_with_retry<F>(
|
||||
&self,
|
||||
mut make_req: F,
|
||||
label: &str,
|
||||
) -> Result<reqwest::Response, reqwest::Error>
|
||||
where
|
||||
F: FnMut() -> reqwest::RequestBuilder,
|
||||
{
|
||||
let mut attempt: u32 = 0;
|
||||
let mut delay = self.retry_base_delay;
|
||||
|
||||
loop {
|
||||
attempt = attempt.saturating_add(1);
|
||||
let resp = make_req().send().await;
|
||||
match resp {
|
||||
Ok(resp) => {
|
||||
if should_retry_status(resp.status()) && attempt < self.retry_max_attempts {
|
||||
let sleep_for = jitter_delay(delay);
|
||||
debug!(
|
||||
attempt,
|
||||
status = %resp.status(),
|
||||
sleep_ms = sleep_for.as_millis(),
|
||||
label,
|
||||
"Aether request retrying"
|
||||
);
|
||||
sleep(sleep_for).await;
|
||||
let next_delay = delay.checked_mul(2).unwrap_or(self.retry_max_delay);
|
||||
delay = std::cmp::min(next_delay, self.retry_max_delay);
|
||||
continue;
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt < self.retry_max_attempts {
|
||||
let sleep_for = jitter_delay(delay);
|
||||
debug!(
|
||||
attempt,
|
||||
error = %e,
|
||||
sleep_ms = sleep_for.as_millis(),
|
||||
label,
|
||||
"Aether request retrying"
|
||||
);
|
||||
sleep(sleep_for).await;
|
||||
let next_delay = delay.checked_mul(2).unwrap_or(self.retry_max_delay);
|
||||
delay = std::cmp::min(next_delay, self.retry_max_delay);
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_retry_status(status: StatusCode) -> bool {
|
||||
status.is_server_error()
|
||||
|| status == StatusCode::TOO_MANY_REQUESTS
|
||||
|| status == StatusCode::REQUEST_TIMEOUT
|
||||
}
|
||||
|
||||
fn jitter_delay(base: Duration) -> Duration {
|
||||
if base.is_zero() {
|
||||
return base;
|
||||
}
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let jitter_ms = nanos % 100;
|
||||
base + Duration::from_millis(jitter_ms)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Safe DNS resolver for reqwest that reuses validated addresses from DnsCache.
|
||||
//!
|
||||
//! This resolver ensures reqwest connects only to addresses that have been
|
||||
//! previously validated by `target_filter::validate_target()`, eliminating
|
||||
//! the TOCTTOU gap where DNS rebinding could redirect traffic to private IPs.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
|
||||
use crate::target_filter::{self, DnsCache};
|
||||
|
||||
/// A DNS resolver that serves validated public addresses from the shared DnsCache.
|
||||
///
|
||||
/// When reqwest needs to resolve a hostname, this resolver returns addresses
|
||||
/// from the cache (populated by `validate_target()` during request validation).
|
||||
/// If the hostname is not in cache (shouldn't happen in normal flow), it
|
||||
/// performs a fresh resolution with private-IP filtering.
|
||||
pub struct SafeDnsResolver {
|
||||
dns_cache: Arc<DnsCache>,
|
||||
}
|
||||
|
||||
impl SafeDnsResolver {
|
||||
pub fn new(dns_cache: Arc<DnsCache>) -> Self {
|
||||
Self { dns_cache }
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for SafeDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let dns_cache = Arc::clone(&self.dns_cache);
|
||||
Box::pin(async move {
|
||||
let host = name.as_str();
|
||||
|
||||
// Try cache first (should be populated by validate_target).
|
||||
// reqwest resolves by hostname only (no port), so use host-only lookup.
|
||||
if let Some(addrs) = dns_cache.get_by_host(host).await {
|
||||
let socket_addrs: Vec<SocketAddr> = (*addrs).clone();
|
||||
return Ok(Box::new(socket_addrs.into_iter()) as Addrs);
|
||||
}
|
||||
|
||||
// Fallback: resolve with private-IP filtering (defensive).
|
||||
// This path should rarely be hit since validate_target() runs first.
|
||||
// We don't know the real port here (reqwest Resolve only gives hostname),
|
||||
// so resolve directly without caching to avoid polluting the cache with
|
||||
// an incorrect port-based key.
|
||||
let addr_str = format!("{}:0", host);
|
||||
let resolved: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
|
||||
.filter(|addr| !target_filter::is_private_ip(&addr.ip()))
|
||||
.collect();
|
||||
|
||||
if resolved.is_empty() {
|
||||
return Err(Box::new(std::io::Error::other(format!(
|
||||
"all resolved addresses for {} are private/reserved",
|
||||
host
|
||||
)))
|
||||
as Box<dyn std::error::Error + Send + Sync>);
|
||||
}
|
||||
|
||||
Ok(Box::new(resolved.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
//! Systemd service installation for aether-proxy.
|
||||
//!
|
||||
//! Called from the setup TUI when the user enables "Install Service".
|
||||
//! The unit file points to the binary and config at their current
|
||||
//! absolute paths -- no files are copied.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const UNIT_PATH: &str = "/etc/systemd/system/aether-proxy.service";
|
||||
const SERVICE_NAME: &str = "aether-proxy";
|
||||
|
||||
/// Whether systemd service installation is possible (systemd present + root).
|
||||
pub fn is_available() -> bool {
|
||||
is_systemd_available() && is_root()
|
||||
}
|
||||
|
||||
/// Install aether-proxy as a systemd service. Must be run as root.
|
||||
pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
|
||||
if !is_systemd_available() {
|
||||
anyhow::bail!("systemd not available");
|
||||
}
|
||||
if !is_root() {
|
||||
anyhow::bail!("root required, use: sudo ./aether-proxy setup");
|
||||
}
|
||||
|
||||
let exe_path = std::env::current_exe()?.canonicalize()?;
|
||||
let exe_str = exe_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("binary path contains invalid UTF-8"))?;
|
||||
|
||||
let config_abs = std::fs::canonicalize(config_path)?;
|
||||
let config_str = config_abs
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("config path contains invalid UTF-8"))?;
|
||||
|
||||
let working_dir = config_abs
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/"))
|
||||
.to_str()
|
||||
.unwrap_or("/");
|
||||
|
||||
// Stop existing service if running (ignore errors)
|
||||
if Path::new(UNIT_PATH).exists() {
|
||||
eprintln!(" Stopping existing service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["stop", SERVICE_NAME])
|
||||
.status();
|
||||
}
|
||||
|
||||
// Write unit file
|
||||
eprintln!(" Generating systemd unit file...");
|
||||
eprintln!(" Binary: {}", exe_str);
|
||||
eprintln!(" Config: {}", config_str);
|
||||
eprintln!(" WorkDir: {}", working_dir);
|
||||
|
||||
let unit_content = format!(
|
||||
"[Unit]\n\
|
||||
Description=Aether Proxy\n\
|
||||
After=network.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
WorkingDirectory={working_dir}\n\
|
||||
Environment=AETHER_PROXY_CONFIG={config_str}\n\
|
||||
ExecStart={exe_str}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
LimitNOFILE=65535\n\
|
||||
UMask=0077\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n",
|
||||
);
|
||||
std::fs::write(UNIT_PATH, &unit_content)?;
|
||||
|
||||
// Reload and enable
|
||||
eprintln!(" Enabling and starting service...");
|
||||
run_cmd("systemctl", &["daemon-reload"])?;
|
||||
run_cmd("systemctl", &["enable", "--now", SERVICE_NAME])?;
|
||||
|
||||
// Verify
|
||||
eprintln!();
|
||||
let output = Command::new("systemctl")
|
||||
.args(["is-active", SERVICE_NAME])
|
||||
.output()?;
|
||||
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
if state == "active" {
|
||||
eprintln!(" Service started successfully!");
|
||||
} else {
|
||||
eprintln!(" Service state: {} (check logs)", state);
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Commands:");
|
||||
eprintln!(" ./aether-proxy status # service status");
|
||||
eprintln!(" ./aether-proxy logs # tail logs");
|
||||
eprintln!(" sudo ./aether-proxy restart # restart");
|
||||
eprintln!(" sudo ./aether-proxy stop # stop");
|
||||
eprintln!(" sudo ./aether-proxy uninstall # remove service");
|
||||
eprintln!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_systemd_available() -> bool {
|
||||
Command::new("systemctl")
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn is_root() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a systemd unit file is currently installed.
|
||||
pub fn is_installed() -> bool {
|
||||
Path::new(UNIT_PATH).exists()
|
||||
}
|
||||
|
||||
/// Remove the systemd service (called from setup TUI when Install Service is toggled off).
|
||||
pub fn uninstall_service() -> anyhow::Result<()> {
|
||||
if !Path::new(UNIT_PATH).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!(" Stopping and removing existing service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["disable", "--now", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
std::fs::remove_file(UNIT_PATH)?;
|
||||
eprintln!(" Removed {}", UNIT_PATH);
|
||||
run_cmd("systemctl", &["daemon-reload"])?;
|
||||
eprintln!(" Service uninstalled.");
|
||||
eprintln!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the systemd service is currently active.
|
||||
pub fn is_service_active() -> bool {
|
||||
std::path::Path::new(UNIT_PATH).exists()
|
||||
&& Command::new("systemctl")
|
||||
.args(["is-active", "--quiet", SERVICE_NAME])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── CLI subcommands (systemd wrappers) ──────────────────────────────────────
|
||||
|
||||
fn ensure_service_installed() -> anyhow::Result<()> {
|
||||
if !std::path::Path::new(UNIT_PATH).exists() {
|
||||
anyhow::bail!("service not installed, run `sudo ./aether-proxy setup` first");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_root_and_service() -> anyhow::Result<()> {
|
||||
ensure_service_installed()?;
|
||||
if !is_root() {
|
||||
anyhow::bail!("root required, use: sudo ./aether-proxy <command>");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy status` -- show service status.
|
||||
pub fn cmd_status() -> anyhow::Result<()> {
|
||||
ensure_service_installed()?;
|
||||
let status = Command::new("systemctl")
|
||||
.args(["status", SERVICE_NAME])
|
||||
.status()?;
|
||||
// systemctl status returns non-zero when inactive; that's fine
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
/// `aether-proxy logs` -- tail service logs.
|
||||
pub fn cmd_logs() -> anyhow::Result<()> {
|
||||
ensure_service_installed()?;
|
||||
let status = Command::new("journalctl")
|
||||
.args(["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"])
|
||||
.status()?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
/// `aether-proxy start` -- start the service.
|
||||
pub fn cmd_start() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
run_cmd("systemctl", &["start", SERVICE_NAME])?;
|
||||
eprintln!(" Service started.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy restart` -- restart the service.
|
||||
pub fn cmd_restart() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
run_cmd("systemctl", &["restart", SERVICE_NAME])?;
|
||||
eprintln!(" Service restarted.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy stop` -- stop the service.
|
||||
pub fn cmd_stop() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
run_cmd("systemctl", &["stop", SERVICE_NAME])?;
|
||||
eprintln!(" Service stopped.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy uninstall` -- disable and remove the systemd service.
|
||||
pub fn cmd_uninstall() -> anyhow::Result<()> {
|
||||
ensure_root_and_service()?;
|
||||
|
||||
eprintln!(" Stopping and disabling service...");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["disable", "--now", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
if std::path::Path::new(UNIT_PATH).exists() {
|
||||
std::fs::remove_file(UNIT_PATH)?;
|
||||
eprintln!(" Removed {}", UNIT_PATH);
|
||||
}
|
||||
|
||||
run_cmd("systemctl", &["daemon-reload"])?;
|
||||
eprintln!(" Service uninstalled.");
|
||||
eprintln!();
|
||||
eprintln!(" Config file and TLS certs are preserved. Remove manually if needed.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_cmd(program: &str, args: &[&str]) -> anyhow::Result<()> {
|
||||
let display = format!("{} {}", program, args.join(" "));
|
||||
eprintln!(" > {}", display);
|
||||
|
||||
let status = Command::new(program).args(args).status()?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("command failed: {}", display);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,868 +0,0 @@
|
||||
//! Interactive TUI for configuring aether-proxy.
|
||||
//!
|
||||
//! Launched via `aether-proxy setup [path]`. Presents a full-screen form
|
||||
//! backed by ratatui where the user can navigate fields, edit values, and
|
||||
//! save to a TOML config file. Supports multi-server configuration via
|
||||
//! a tabbed interface.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use crate::config::{ConfigFile, ServerEntry};
|
||||
|
||||
/// Outcome of the setup wizard, returned to the caller.
|
||||
pub enum SetupOutcome {
|
||||
/// Config saved; systemd service installed and started.
|
||||
ServiceInstalled,
|
||||
/// Config saved; no service -- caller should start the proxy directly.
|
||||
ReadyToRun(PathBuf),
|
||||
/// User quit without saving.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Column width reserved for the field label (chars).
|
||||
const LABEL_WIDTH: usize = 22;
|
||||
|
||||
// -- Field types --------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FieldKind {
|
||||
Text,
|
||||
Secret,
|
||||
Bool,
|
||||
LogLevel,
|
||||
}
|
||||
|
||||
struct Field {
|
||||
label: &'static str,
|
||||
key: &'static str,
|
||||
value: String,
|
||||
kind: FieldKind,
|
||||
required: bool,
|
||||
help: &'static str,
|
||||
}
|
||||
// -- Server tab ---------------------------------------------------------------
|
||||
|
||||
/// A single server tab's editable fields.
|
||||
struct ServerTab {
|
||||
fields: Vec<Field>,
|
||||
}
|
||||
|
||||
impl ServerTab {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
Field {
|
||||
label: "Aether URL",
|
||||
key: "aether_url",
|
||||
value: String::new(),
|
||||
kind: FieldKind::Text,
|
||||
required: true,
|
||||
help: "Aether URL (e.g. https://aether.example.com)",
|
||||
},
|
||||
Field {
|
||||
label: "Management Token",
|
||||
key: "management_token",
|
||||
value: String::new(),
|
||||
kind: FieldKind::Secret,
|
||||
required: true,
|
||||
help: "Aether Management Token (ae_xxx)",
|
||||
},
|
||||
Field {
|
||||
label: "Node Name",
|
||||
key: "node_name",
|
||||
value: "proxy-01".into(),
|
||||
kind: FieldKind::Text,
|
||||
required: true,
|
||||
help: "Node name for identification in Aether dashboard",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn from_entry(entry: &ServerEntry) -> Self {
|
||||
let mut tab = Self::new();
|
||||
tab.fields[0].value = entry.aether_url.clone();
|
||||
tab.fields[1].value = entry.management_token.clone();
|
||||
if let Some(ref name) = entry.node_name {
|
||||
tab.fields[2].value = name.clone();
|
||||
}
|
||||
tab
|
||||
}
|
||||
}
|
||||
|
||||
// -- App state ----------------------------------------------------------------
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Mode {
|
||||
Normal,
|
||||
Editing,
|
||||
}
|
||||
|
||||
struct App {
|
||||
server_tabs: Vec<ServerTab>,
|
||||
active_tab: usize,
|
||||
global_fields: Vec<Field>,
|
||||
selected: usize,
|
||||
mode: Mode,
|
||||
edit_buffer: String,
|
||||
edit_cursor: usize,
|
||||
config_path: PathBuf,
|
||||
modified: bool,
|
||||
message: Option<(String, Instant, bool)>,
|
||||
scroll_offset: usize,
|
||||
saved_once: bool,
|
||||
pending_quit: bool,
|
||||
confirm_delete: bool,
|
||||
}
|
||||
impl App {
|
||||
fn new(config_path: PathBuf) -> Self {
|
||||
Self {
|
||||
server_tabs: vec![ServerTab::new()],
|
||||
active_tab: 0,
|
||||
global_fields: vec![
|
||||
Field {
|
||||
label: "Log Level",
|
||||
key: "log_level",
|
||||
value: "info".into(),
|
||||
kind: FieldKind::LogLevel,
|
||||
required: true,
|
||||
help: "Log level -- Enter to cycle: trace / debug / info / warn / error",
|
||||
},
|
||||
Field {
|
||||
label: "Log JSON",
|
||||
key: "log_json",
|
||||
value: "false".into(),
|
||||
kind: FieldKind::Bool,
|
||||
required: true,
|
||||
help: "Output logs as JSON -- Enter to toggle",
|
||||
},
|
||||
Field {
|
||||
label: "Install Service",
|
||||
key: "install_service",
|
||||
value: if super::service::is_available() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
.into(),
|
||||
kind: FieldKind::Bool,
|
||||
required: true,
|
||||
help: "Install as systemd service (requires root) -- Enter to toggle",
|
||||
},
|
||||
],
|
||||
selected: 0,
|
||||
mode: Mode::Normal,
|
||||
edit_buffer: String::new(),
|
||||
edit_cursor: 0,
|
||||
config_path,
|
||||
modified: false,
|
||||
message: None,
|
||||
scroll_offset: 0,
|
||||
saved_once: false,
|
||||
pending_quit: false,
|
||||
confirm_delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Field accessors (unified index across server + global) ---------------
|
||||
|
||||
fn server_field_count(&self) -> usize {
|
||||
self.server_tabs[self.active_tab].fields.len()
|
||||
}
|
||||
|
||||
fn total_field_count(&self) -> usize {
|
||||
self.server_field_count() + self.global_fields.len()
|
||||
}
|
||||
|
||||
fn selected_field(&self) -> &Field {
|
||||
let sc = self.server_field_count();
|
||||
if self.selected < sc {
|
||||
&self.server_tabs[self.active_tab].fields[self.selected]
|
||||
} else {
|
||||
&self.global_fields[self.selected - sc]
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_field_mut(&mut self) -> &mut Field {
|
||||
let sc = self.server_field_count();
|
||||
if self.selected < sc {
|
||||
&mut self.server_tabs[self.active_tab].fields[self.selected]
|
||||
} else {
|
||||
&mut self.global_fields[self.selected - sc]
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_selection(&mut self) {
|
||||
let max = self.total_field_count();
|
||||
if self.selected >= max {
|
||||
self.selected = max.saturating_sub(1);
|
||||
}
|
||||
self.scroll_offset = 0;
|
||||
self.confirm_delete = false;
|
||||
}
|
||||
// -- Config <-> fields -----------------------------------------------------
|
||||
|
||||
fn load_from_file(&mut self) {
|
||||
if let Ok(cfg) = ConfigFile::load(&self.config_path) {
|
||||
self.apply_config(&cfg);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_config(&mut self, cfg: &ConfigFile) {
|
||||
// Global fields
|
||||
for field in &mut self.global_fields {
|
||||
let val: Option<String> = match field.key {
|
||||
"log_level" => cfg.log_level.clone(),
|
||||
"log_json" => cfg.log_json.map(|v| v.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = val {
|
||||
field.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Server tabs
|
||||
let servers = cfg.effective_servers();
|
||||
if servers.is_empty() {
|
||||
let mut tab = ServerTab::new();
|
||||
// Single-server fallback: use top-level node_name
|
||||
if let Some(ref name) = cfg.node_name {
|
||||
tab.fields[2].value = name.clone();
|
||||
}
|
||||
self.server_tabs = vec![tab];
|
||||
} else {
|
||||
self.server_tabs = servers.iter().map(ServerTab::from_entry).collect();
|
||||
// For single-server mode, node_name might be in top-level only
|
||||
if self.server_tabs.len() == 1 && self.server_tabs[0].fields[2].value.is_empty() {
|
||||
if let Some(ref name) = cfg.node_name {
|
||||
self.server_tabs[0].fields[2].value = name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active_tab = 0;
|
||||
self.selected = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
fn to_config(&self) -> ConfigFile {
|
||||
let get_global = |key: &str| -> Option<String> {
|
||||
self.global_fields
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.map(|f| f.value.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
|
||||
let get_tab = |tab: &ServerTab, key: &str| -> Option<String> {
|
||||
tab.fields
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.map(|f| f.value.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
|
||||
let mut cfg = ConfigFile {
|
||||
log_level: get_global("log_level"),
|
||||
log_json: get_global("log_json").and_then(|v| v.parse().ok()),
|
||||
..ConfigFile::default()
|
||||
};
|
||||
|
||||
// Always write [[servers]] format; old top-level fields are read-only compat
|
||||
cfg.servers = self
|
||||
.server_tabs
|
||||
.iter()
|
||||
.map(|tab| ServerEntry {
|
||||
aether_url: get_tab(tab, "aether_url").unwrap_or_default(),
|
||||
management_token: get_tab(tab, "management_token").unwrap_or_default(),
|
||||
node_name: get_tab(tab, "node_name"),
|
||||
})
|
||||
.collect();
|
||||
cfg
|
||||
}
|
||||
|
||||
fn save(&mut self) -> anyhow::Result<()> {
|
||||
let cfg = self.to_config();
|
||||
cfg.save(&self.config_path)?;
|
||||
// Restrict config file permissions to owner-only (contains management token).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ =
|
||||
std::fs::set_permissions(&self.config_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
self.modified = false;
|
||||
self.saved_once = true;
|
||||
self.message = Some((
|
||||
format!("saved to {}", self.config_path.display()),
|
||||
Instant::now(),
|
||||
false,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
// -- Scrolling ---------------------------------------------------------------
|
||||
|
||||
fn ensure_visible(&mut self, visible_rows: usize) {
|
||||
if visible_rows == 0 {
|
||||
return;
|
||||
}
|
||||
// Account for separator line between server and global fields
|
||||
let display_row = if self.selected >= self.server_field_count() {
|
||||
self.selected + 1
|
||||
} else {
|
||||
self.selected
|
||||
};
|
||||
if display_row < self.scroll_offset {
|
||||
self.scroll_offset = display_row;
|
||||
} else if display_row >= self.scroll_offset + visible_rows {
|
||||
self.scroll_offset = display_row - visible_rows + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Key handling -------------------------------------------------------------
|
||||
|
||||
/// Returns `true` when the app should exit.
|
||||
fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
// Expire old messages (but keep quit-confirmation messages alive)
|
||||
if let Some((_, when, _)) = &self.message {
|
||||
if !self.pending_quit && !self.confirm_delete && when.elapsed() > Duration::from_secs(4)
|
||||
{
|
||||
self.message = None;
|
||||
}
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
Mode::Normal => self.handle_normal(key),
|
||||
Mode::Editing => {
|
||||
self.handle_edit(key);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_normal(&mut self, key: KeyEvent) -> bool {
|
||||
// -- Quit handling (with unsaved-changes confirmation) -----------------
|
||||
let is_quit_key = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc);
|
||||
|
||||
if is_quit_key {
|
||||
if !self.modified || self.pending_quit {
|
||||
return true;
|
||||
}
|
||||
self.pending_quit = true;
|
||||
self.confirm_delete = false;
|
||||
self.message = Some((
|
||||
"unsaved changes! q again to discard, ^S to save".into(),
|
||||
Instant::now(),
|
||||
true,
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any other key cancels pending quit / pending delete
|
||||
if self.pending_quit {
|
||||
self.pending_quit = false;
|
||||
self.message = None;
|
||||
}
|
||||
if self.confirm_delete && !matches!(key.code, KeyCode::Delete | KeyCode::Char('x')) {
|
||||
self.confirm_delete = false;
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('s')
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| key.modifiers.contains(KeyModifiers::SUPER) =>
|
||||
{
|
||||
if let Err(e) = self.save() {
|
||||
self.message = Some((format!("error: {}", e), Instant::now(), true));
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.selected + 1 < self.total_field_count() {
|
||||
self.selected += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => self.selected = 0,
|
||||
KeyCode::End => self.selected = self.total_field_count() - 1,
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let kind = self.selected_field().kind;
|
||||
let key_str = self.selected_field().key;
|
||||
let value = self.selected_field().value.clone();
|
||||
match kind {
|
||||
FieldKind::Bool => {
|
||||
let toggled = if value == "true" { "false" } else { "true" };
|
||||
if key_str == "install_service"
|
||||
&& toggled == "true"
|
||||
&& !super::service::is_available()
|
||||
{
|
||||
self.message = Some((
|
||||
"requires root with systemd, use: sudo aether-proxy setup".into(),
|
||||
Instant::now(),
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
self.selected_field_mut().value = toggled.into();
|
||||
self.modified = true;
|
||||
}
|
||||
}
|
||||
FieldKind::LogLevel => {
|
||||
const LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
|
||||
let idx = LEVELS.iter().position(|l| *l == value).unwrap_or(2);
|
||||
self.selected_field_mut().value = LEVELS[(idx + 1) % LEVELS.len()].into();
|
||||
self.modified = true;
|
||||
}
|
||||
_ => {
|
||||
self.edit_buffer = value;
|
||||
self.edit_cursor = self.edit_buffer.chars().count();
|
||||
self.mode = Mode::Editing;
|
||||
}
|
||||
}
|
||||
}
|
||||
// -- Tab navigation --
|
||||
KeyCode::Tab => {
|
||||
if self.server_tabs.len() > 1 {
|
||||
self.active_tab = (self.active_tab + 1) % self.server_tabs.len();
|
||||
self.clamp_selection();
|
||||
}
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
if self.server_tabs.len() > 1 {
|
||||
self.active_tab = if self.active_tab == 0 {
|
||||
self.server_tabs.len() - 1
|
||||
} else {
|
||||
self.active_tab - 1
|
||||
};
|
||||
self.clamp_selection();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c @ '1'..='9') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let idx = (c as usize) - ('1' as usize);
|
||||
if idx < self.server_tabs.len() && idx != self.active_tab {
|
||||
self.active_tab = idx;
|
||||
self.clamp_selection();
|
||||
}
|
||||
}
|
||||
// -- Add / remove server --
|
||||
KeyCode::Char('+') | KeyCode::Char('a') => {
|
||||
self.server_tabs.push(ServerTab::new());
|
||||
self.active_tab = self.server_tabs.len() - 1;
|
||||
self.selected = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.modified = true;
|
||||
self.message = Some((
|
||||
format!("added server {}", self.server_tabs.len()),
|
||||
Instant::now(),
|
||||
false,
|
||||
));
|
||||
}
|
||||
KeyCode::Delete | KeyCode::Char('x') => {
|
||||
if self.server_tabs.len() <= 1 {
|
||||
self.message =
|
||||
Some(("cannot remove the last server".into(), Instant::now(), true));
|
||||
} else if self.confirm_delete {
|
||||
let removed = self.active_tab + 1;
|
||||
self.server_tabs.remove(self.active_tab);
|
||||
self.active_tab = self.active_tab.min(self.server_tabs.len() - 1);
|
||||
self.clamp_selection();
|
||||
self.modified = true;
|
||||
self.message =
|
||||
Some((format!("server {} removed", removed), Instant::now(), false));
|
||||
} else {
|
||||
self.confirm_delete = true;
|
||||
self.message = Some((
|
||||
"press Delete/x again to remove this server".into(),
|
||||
Instant::now(),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_edit(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.validate_edit() {
|
||||
self.selected_field_mut().value = self.edit_buffer.clone();
|
||||
self.modified = true;
|
||||
self.mode = Mode::Normal;
|
||||
} else {
|
||||
self.message = Some(("invalid format".into(), Instant::now(), true));
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.edit_cursor > 0 {
|
||||
self.edit_cursor -= 1;
|
||||
let byte = self.char_byte_pos(self.edit_cursor);
|
||||
self.edit_buffer.remove(byte);
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.edit_cursor < self.edit_buffer.chars().count() {
|
||||
let byte = self.char_byte_pos(self.edit_cursor);
|
||||
self.edit_buffer.remove(byte);
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.edit_cursor = self.edit_cursor.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
let len = self.edit_buffer.chars().count();
|
||||
if self.edit_cursor < len {
|
||||
self.edit_cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => self.edit_cursor = 0,
|
||||
KeyCode::End => self.edit_cursor = self.edit_buffer.chars().count(),
|
||||
KeyCode::Char(c) => {
|
||||
let byte = self.char_byte_pos(self.edit_cursor);
|
||||
self.edit_buffer.insert(byte, c);
|
||||
self.edit_cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_edit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Byte offset of the char at `char_idx`.
|
||||
fn char_byte_pos(&self, char_idx: usize) -> usize {
|
||||
self.edit_buffer
|
||||
.char_indices()
|
||||
.nth(char_idx)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.edit_buffer.len())
|
||||
}
|
||||
}
|
||||
// -- Rendering ----------------------------------------------------------------
|
||||
|
||||
fn ui(f: &mut Frame, app: &mut App) {
|
||||
let area = f.area();
|
||||
|
||||
let title = if app.modified {
|
||||
" Aether Proxy Setup [*] "
|
||||
} else {
|
||||
" Aether Proxy Setup "
|
||||
};
|
||||
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.title_alignment(ratatui::layout::Alignment::Center)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
// Split: fields | tab bar | footer
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
render_fields(f, app, chunks[0]);
|
||||
render_tab_bar(f, app, chunks[1]);
|
||||
render_footer(f, app, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
let visible = area.height as usize;
|
||||
app.ensure_visible(visible);
|
||||
|
||||
let server_count = app.server_field_count();
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
// display_row tracks the actual row index (including separator)
|
||||
let mut display_row: usize = 0;
|
||||
|
||||
// Server fields
|
||||
for i in 0..server_count {
|
||||
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
|
||||
lines.push(build_field_line(app, i, display_row));
|
||||
}
|
||||
display_row += 1;
|
||||
}
|
||||
|
||||
// Separator line
|
||||
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ----------------------------------------",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
display_row += 1;
|
||||
|
||||
// Global fields
|
||||
for i in 0..app.global_fields.len() {
|
||||
let field_idx = server_count + i;
|
||||
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
|
||||
lines.push(build_field_line(app, field_idx, display_row));
|
||||
}
|
||||
display_row += 1;
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
f.render_widget(paragraph, area);
|
||||
|
||||
// Cursor position while editing
|
||||
if app.mode == Mode::Editing {
|
||||
let sel_display_row = if app.selected >= server_count {
|
||||
app.selected + 1
|
||||
} else {
|
||||
app.selected
|
||||
};
|
||||
let row_in_view = sel_display_row.saturating_sub(app.scroll_offset);
|
||||
let prefix: u16 = 3 + LABEL_WIDTH as u16 + 2;
|
||||
let cx = area.x + prefix + app.edit_cursor as u16;
|
||||
let cy = area.y + row_in_view as u16;
|
||||
if cx < area.x + area.width && cy < area.y + area.height {
|
||||
f.set_cursor_position((cx, cy));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn build_field_line(app: &App, field_idx: usize, _display_row: usize) -> Line<'static> {
|
||||
let sc = app.server_field_count();
|
||||
let field = if field_idx < sc {
|
||||
&app.server_tabs[app.active_tab].fields[field_idx]
|
||||
} else {
|
||||
&app.global_fields[field_idx - sc]
|
||||
};
|
||||
|
||||
let selected = field_idx == app.selected;
|
||||
let indicator = if selected { " > " } else { " " };
|
||||
|
||||
let label_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let padded_label = format!("{:<width$}", field.label, width = LABEL_WIDTH);
|
||||
|
||||
let (value_text, value_style) = if app.mode == Mode::Editing && selected {
|
||||
(app.edit_buffer.clone(), Style::default().fg(Color::Yellow))
|
||||
} else {
|
||||
field_display(field)
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(indicator.to_string(), label_style),
|
||||
Span::styled(padded_label, label_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(value_text, value_style),
|
||||
])
|
||||
}
|
||||
|
||||
/// Returns (display_text, style) for a field in normal mode.
|
||||
fn field_display(field: &Field) -> (String, Style) {
|
||||
if field.value.is_empty() {
|
||||
let text = if field.required {
|
||||
"(required)".into()
|
||||
} else {
|
||||
"-".into()
|
||||
};
|
||||
let color = if field.required {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
return (text, Style::default().fg(color));
|
||||
}
|
||||
|
||||
match field.kind {
|
||||
FieldKind::Secret => (
|
||||
"*".repeat(field.value.len().min(20)),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
FieldKind::Bool => {
|
||||
if field.value == "true" {
|
||||
("[x] on".into(), Style::default().fg(Color::Green))
|
||||
} else {
|
||||
("[ ] off".into(), Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
}
|
||||
FieldKind::LogLevel => {
|
||||
let color = match field.value.as_str() {
|
||||
"trace" => Color::Magenta,
|
||||
"debug" => Color::Blue,
|
||||
"info" => Color::Green,
|
||||
"warn" => Color::Yellow,
|
||||
"error" => Color::Red,
|
||||
_ => Color::White,
|
||||
};
|
||||
(field.value.clone(), Style::default().fg(color))
|
||||
}
|
||||
_ => (field.value.clone(), Style::default().fg(Color::White)),
|
||||
}
|
||||
}
|
||||
fn render_tab_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
for (i, tab) in app.server_tabs.iter().enumerate() {
|
||||
let num = i + 1;
|
||||
let name = tab
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.key == "node_name")
|
||||
.filter(|f| !f.value.is_empty())
|
||||
.map(|f| f.value.clone())
|
||||
.unwrap_or_else(|| format!("Server {}", num));
|
||||
|
||||
let label = format!(" {} {} ", num, name);
|
||||
|
||||
if i == app.active_tab {
|
||||
spans.push(Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(label, Style::default().fg(Color::DarkGray)));
|
||||
}
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
spans.push(Span::styled(" + Add ", Style::default().fg(Color::Green)));
|
||||
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
|
||||
let help = app.selected_field().help;
|
||||
|
||||
let keybindings = if app.mode == Mode::Editing {
|
||||
"Enter confirm Esc cancel"
|
||||
} else if app.server_tabs.len() > 1 {
|
||||
"j/k select Enter edit Tab switch + add x remove ^S save q quit"
|
||||
} else {
|
||||
"j/k select Enter edit + add server ^S save q quit"
|
||||
};
|
||||
|
||||
let mut status_spans: Vec<Span> = vec![Span::styled(
|
||||
format!(" {}", keybindings),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)];
|
||||
|
||||
if let Some((msg, _, is_err)) = &app.message {
|
||||
let color = if *is_err { Color::Red } else { Color::Green };
|
||||
status_spans.push(Span::raw(" "));
|
||||
status_spans.push(Span::styled(msg.clone(), Style::default().fg(color)));
|
||||
}
|
||||
|
||||
let footer_text = vec![
|
||||
Line::raw(""),
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", help),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(status_spans),
|
||||
];
|
||||
|
||||
let footer = Paragraph::new(footer_text).block(
|
||||
Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(Color::DarkGray)),
|
||||
);
|
||||
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
// -- Entry point --------------------------------------------------------------
|
||||
|
||||
pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(config_path.clone());
|
||||
app.load_from_file();
|
||||
|
||||
let result = event_loop(&mut terminal, &mut app);
|
||||
|
||||
terminal::disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result?;
|
||||
|
||||
// -- Post-TUI: decide outcome ---------------------------------------------
|
||||
|
||||
if !app.saved_once {
|
||||
return Ok(SetupOutcome::Cancelled);
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Config saved to {}", config_path.display());
|
||||
eprintln!();
|
||||
|
||||
let wants_service = app
|
||||
.global_fields
|
||||
.iter()
|
||||
.find(|f| f.key == "install_service")
|
||||
.map(|f| f.value == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
if wants_service {
|
||||
match super::service::install_service(&config_path) {
|
||||
Ok(()) => return Ok(SetupOutcome::ServiceInstalled),
|
||||
Err(e) => {
|
||||
eprintln!(" Service install failed: {}", e);
|
||||
eprintln!(" Starting proxy directly instead.\n");
|
||||
}
|
||||
}
|
||||
} else if super::service::is_installed() {
|
||||
if let Err(e) = super::service::uninstall_service() {
|
||||
eprintln!(" Service uninstall failed: {}", e);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SetupOutcome::ReadyToRun(config_path))
|
||||
}
|
||||
|
||||
fn event_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(200))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press && app.handle_key(key) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
//! Self-upgrade for aether-proxy.
|
||||
//!
|
||||
//! Downloads a release from GitHub, verifies SHA256 checksum, and atomically
|
||||
//! replaces the running binary. Restarts the systemd service if active.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
const GITHUB_REPO: &str = "fawney19/Aether";
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// ── GitHub API types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
|
||||
fn detect_platform() -> &'static str {
|
||||
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
|
||||
"linux-amd64"
|
||||
} else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
|
||||
"linux-arm64"
|
||||
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
|
||||
"macos-amd64"
|
||||
} else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
|
||||
"macos-arm64"
|
||||
} else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
|
||||
"windows-amd64"
|
||||
} else {
|
||||
// All supported targets are covered above; this is unreachable for
|
||||
// any platform we actually build for.
|
||||
panic!("unsupported platform: compile-time target not in the supported matrix")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GitHub HTTP client ───────────────────────────────────────────────────────
|
||||
|
||||
fn build_github_client() -> anyhow::Result<reqwest::Client> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
|
||||
);
|
||||
}
|
||||
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
|
||||
);
|
||||
|
||||
Ok(reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.user_agent(format!("aether-proxy/{}", CURRENT_VERSION))
|
||||
.default_headers(headers)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
// ── Release fetching ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_release(
|
||||
client: &reqwest::Client,
|
||||
version: Option<&str>,
|
||||
) -> anyhow::Result<GithubRelease> {
|
||||
match version {
|
||||
Some(ver) => {
|
||||
// Accept both "proxy-v0.2.0" and bare "0.2.0"
|
||||
let tag = if ver.starts_with("proxy-v") {
|
||||
ver.to_string()
|
||||
} else {
|
||||
format!("proxy-v{}", ver)
|
||||
};
|
||||
let url = format!(
|
||||
"{}/repos/{}/releases/tags/{}",
|
||||
GITHUB_API_BASE, GITHUB_REPO, tag
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("release '{}' not found (HTTP {}): {}", tag, status, body);
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
None => {
|
||||
// List releases and find the latest proxy-v* tag
|
||||
let url = format!(
|
||||
"{}/repos/{}/releases?per_page=20",
|
||||
GITHUB_API_BASE, GITHUB_REPO
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("failed to list releases (HTTP {}): {}", status, body);
|
||||
}
|
||||
let releases: Vec<GithubRelease> = resp.json().await?;
|
||||
releases
|
||||
.into_iter()
|
||||
.find(|r| r.tag_name.starts_with("proxy-v"))
|
||||
.ok_or_else(|| anyhow::anyhow!("no proxy-v* release found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download via GitHub release direct links ─────────────────────────────────
|
||||
|
||||
/// Download a release asset via the public direct download URL:
|
||||
/// `https://github.com/{repo}/releases/download/{tag}/{filename}`
|
||||
async fn download_release_file(
|
||||
client: &reqwest::Client,
|
||||
tag: &str,
|
||||
filename: &str,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let url = format!(
|
||||
"https://github.com/{}/releases/download/{}/{}",
|
||||
GITHUB_REPO, tag, filename
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(reqwest::header::ACCEPT, "application/octet-stream")
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"download failed for '{}' (HTTP {})",
|
||||
filename,
|
||||
resp.status(),
|
||||
);
|
||||
}
|
||||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
fn parse_checksum(sums_text: &str, filename: &str) -> anyhow::Result<String> {
|
||||
for line in sums_text.lines() {
|
||||
// Format: "<hash> <filename>" (GNU coreutils convention)
|
||||
let mut parts = line.split_ascii_whitespace();
|
||||
let (Some(hash), Some(name)) = (parts.next(), parts.next()) else {
|
||||
continue;
|
||||
};
|
||||
if name == filename || name.ends_with(filename) {
|
||||
return Ok(hash.to_lowercase());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("checksum for '{}' not found in SHA256SUMS.txt", filename);
|
||||
}
|
||||
|
||||
async fn download_and_verify(
|
||||
client: &reqwest::Client,
|
||||
tag: &str,
|
||||
platform: &str,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let archive_name = format!("aether-proxy-{}.tar.gz", platform);
|
||||
|
||||
eprintln!(" Downloading {}...", archive_name);
|
||||
let (archive_bytes, checksum_bytes) = tokio::try_join!(
|
||||
download_release_file(client, tag, &archive_name),
|
||||
download_release_file(client, tag, "SHA256SUMS.txt"),
|
||||
)?;
|
||||
let checksum_text = String::from_utf8(checksum_bytes)?;
|
||||
|
||||
eprintln!(
|
||||
" Downloaded {} ({} bytes)",
|
||||
archive_name,
|
||||
archive_bytes.len()
|
||||
);
|
||||
|
||||
// Verify SHA256
|
||||
let expected_hash = parse_checksum(&checksum_text, &archive_name)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&archive_bytes);
|
||||
let actual_hash = hex::encode(hasher.finalize());
|
||||
|
||||
if actual_hash != expected_hash {
|
||||
anyhow::bail!(
|
||||
"SHA256 mismatch for {}:\n expected: {}\n actual: {}",
|
||||
archive_name,
|
||||
expected_hash,
|
||||
actual_hash
|
||||
);
|
||||
}
|
||||
eprintln!(" SHA256 verified: {}", &actual_hash[..16]);
|
||||
|
||||
extract_binary(&archive_bytes, dest)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Archive extraction ───────────────────────────────────────────────────────
|
||||
|
||||
fn extract_binary(archive_bytes: &[u8], dest: &Path) -> anyhow::Result<()> {
|
||||
use flate2::read::GzDecoder;
|
||||
use tar::Archive;
|
||||
|
||||
// Guard against decompression bombs
|
||||
const MAX_BINARY_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
let decoder = GzDecoder::new(archive_bytes);
|
||||
let mut archive = Archive::new(decoder);
|
||||
|
||||
let binary_name = if cfg!(target_os = "windows") {
|
||||
"aether-proxy.exe"
|
||||
} else {
|
||||
"aether-proxy"
|
||||
};
|
||||
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
// Only accept regular files -- reject symlinks to prevent write-through attacks
|
||||
if entry.header().entry_type() != tar::EntryType::Regular {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path()?;
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(binary_name) {
|
||||
let size = entry.header().size()?;
|
||||
if size > MAX_BINARY_SIZE {
|
||||
anyhow::bail!(
|
||||
"binary too large ({} bytes, max {} bytes)",
|
||||
size,
|
||||
MAX_BINARY_SIZE
|
||||
);
|
||||
}
|
||||
let mut file = std::fs::File::create(dest)?;
|
||||
std::io::copy(&mut entry, &mut file)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("'{}' not found in archive", binary_name);
|
||||
}
|
||||
|
||||
// ── Atomic binary replacement ────────────────────────────────────────────────
|
||||
|
||||
fn atomic_replace(new_binary: &Path) -> anyhow::Result<PathBuf> {
|
||||
let current_exe = std::env::current_exe()?.canonicalize()?;
|
||||
let backup_path = current_exe.with_extension("bak");
|
||||
|
||||
// Remove stale backup
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
// current -> .bak
|
||||
std::fs::rename(¤t_exe, &backup_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to backup current binary '{}' -> '{}': {}",
|
||||
current_exe.display(),
|
||||
backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// new -> current
|
||||
if let Err(e) = std::fs::rename(new_binary, ¤t_exe) {
|
||||
eprintln!(" ERROR: failed to place new binary, rolling back...");
|
||||
let _ = std::fs::rename(&backup_path, ¤t_exe);
|
||||
anyhow::bail!(
|
||||
"failed to install new binary '{}' -> '{}': {}",
|
||||
new_binary.display(),
|
||||
current_exe.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(" Binary replaced: {}", current_exe.display());
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RestartMode {
|
||||
BestEffort,
|
||||
Required,
|
||||
}
|
||||
|
||||
async fn execute_upgrade(
|
||||
version: Option<&str>,
|
||||
require_root: bool,
|
||||
restart_mode: RestartMode,
|
||||
) -> anyhow::Result<()> {
|
||||
// Resolve exe path once; reuse throughout the function
|
||||
let current_exe = std::env::current_exe()?.canonicalize()?;
|
||||
let exe_dir = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?;
|
||||
let temp_path = exe_dir.join(".aether-proxy.upgrade.tmp");
|
||||
|
||||
if require_root {
|
||||
if !super::service::is_root() {
|
||||
anyhow::bail!("automatic upgrade requires root privileges");
|
||||
}
|
||||
} else if !super::service::is_root() {
|
||||
// Check write permission to binary directory for manual upgrade mode.
|
||||
let test_path = exe_dir.join(".aether-proxy.write-test");
|
||||
match std::fs::File::create(&test_path) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&test_path);
|
||||
}
|
||||
Err(_) => {
|
||||
anyhow::bail!(
|
||||
"no write access to {}. Use: sudo aether-proxy upgrade",
|
||||
exe_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let platform = detect_platform();
|
||||
eprintln!(" Platform: {}", platform);
|
||||
eprintln!(" Current version: {}", CURRENT_VERSION);
|
||||
|
||||
let client = build_github_client()?;
|
||||
let release = fetch_release(&client, version).await?;
|
||||
let target_tag = &release.tag_name;
|
||||
let target_semver = target_tag.strip_prefix("proxy-v").unwrap_or(target_tag);
|
||||
|
||||
eprintln!(" Target version: {} ({})", target_tag, release.name);
|
||||
|
||||
if target_semver == CURRENT_VERSION {
|
||||
eprintln!(
|
||||
" Already running version {}, nothing to do.",
|
||||
CURRENT_VERSION
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Upgrading: {} -> {}", CURRENT_VERSION, target_semver);
|
||||
eprintln!();
|
||||
|
||||
if let Err(e) = download_and_verify(&client, target_tag, platform, &temp_path).await {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
let backup_path = match atomic_replace(&temp_path) {
|
||||
Ok(backup) => backup,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
match restart_mode {
|
||||
RestartMode::BestEffort => {
|
||||
// Restart systemd service if running.
|
||||
// Use best-effort: binary is already replaced, so a restart failure should
|
||||
// not abort the whole upgrade -- the user can restart manually.
|
||||
if super::service::is_service_active() {
|
||||
if super::service::is_root() {
|
||||
eprintln!(" Restarting systemd service...");
|
||||
match super::service::run_cmd("systemctl", &["restart", "aether-proxy"]) {
|
||||
Ok(()) => eprintln!(" Service restarted."),
|
||||
Err(e) => {
|
||||
eprintln!(" WARNING: failed to restart service: {}", e);
|
||||
eprintln!(" Run manually: sudo systemctl restart aether-proxy");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!(" Systemd service is active, but restart requires root.");
|
||||
eprintln!(" Run: sudo systemctl restart aether-proxy");
|
||||
eprintln!(" Skipping restart.");
|
||||
}
|
||||
} else {
|
||||
eprintln!(" No active systemd service detected, skipping restart.");
|
||||
}
|
||||
}
|
||||
RestartMode::Required => {
|
||||
if !super::service::is_root() {
|
||||
anyhow::bail!("automatic upgrade requires root privileges");
|
||||
}
|
||||
eprintln!(" Restarting systemd service...");
|
||||
super::service::run_cmd("systemctl", &["restart", "aether-proxy"])?;
|
||||
eprintln!(" Service restarted.");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Upgrade complete!");
|
||||
eprintln!(
|
||||
" Backup kept at: {} (will be cleaned up on next upgrade)",
|
||||
backup_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `aether-proxy upgrade [version]` -- self-upgrade from GitHub releases.
|
||||
pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
|
||||
execute_upgrade(version.as_deref(), false, RestartMode::BestEffort).await
|
||||
}
|
||||
|
||||
/// Perform automatic upgrade to a specific version.
|
||||
///
|
||||
/// This path is designed for server-pushed upgrades in systemd/root scenarios:
|
||||
/// it requires root and requires a successful `systemctl restart aether-proxy`.
|
||||
pub async fn perform_upgrade(version: &str) -> anyhow::Result<()> {
|
||||
execute_upgrade(Some(version), true, RestartMode::Required).await
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Shared application state passed to all subsystems.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::registration::client::AetherClient;
|
||||
use crate::runtime::SharedDynamicConfig;
|
||||
use crate::target_filter::DnsCache;
|
||||
|
||||
/// Central application state shared across all servers/tunnels.
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
/// DNS cache for upstream target resolution (shared).
|
||||
pub dns_cache: Arc<DnsCache>,
|
||||
/// Reqwest client for tunnel upstream requests (shared).
|
||||
pub reqwest_client: reqwest::Client,
|
||||
/// Shared TLS config for tunnel WebSocket connections (avoids re-parsing root CAs on each reconnect).
|
||||
pub tunnel_tls_config: Arc<rustls::ClientConfig>,
|
||||
}
|
||||
|
||||
/// Per-server state: one instance per Aether server connection.
|
||||
pub struct ServerContext {
|
||||
/// Human-readable label for logging (e.g. "server-0").
|
||||
pub server_label: String,
|
||||
/// Aether server URL for this connection.
|
||||
pub aether_url: String,
|
||||
/// Management token for this server.
|
||||
pub management_token: String,
|
||||
/// Resolved node name at registration time (per-server override or global fallback).
|
||||
/// After startup, the active node_name is read from `dynamic` (may be updated remotely).
|
||||
#[allow(dead_code)]
|
||||
pub node_name: String,
|
||||
/// Node ID assigned by this Aether server.
|
||||
pub node_id: Arc<RwLock<String>>,
|
||||
/// API client for this server.
|
||||
pub aether_client: Arc<AetherClient>,
|
||||
/// Dynamic config from this server's heartbeat ACKs.
|
||||
pub dynamic: SharedDynamicConfig,
|
||||
/// Per-server active connection count.
|
||||
pub active_connections: Arc<AtomicU64>,
|
||||
/// Per-server request/latency metrics.
|
||||
pub metrics: Arc<ProxyMetrics>,
|
||||
}
|
||||
|
||||
/// Aggregate metrics for reporting to Aether.
|
||||
pub struct ProxyMetrics {
|
||||
pub total_requests: AtomicU64,
|
||||
/// Cumulative connection-establishment latency in nanoseconds
|
||||
/// (DNS + TCP/TLS + TTFB, excludes response body streaming).
|
||||
pub total_latency_ns: AtomicU64,
|
||||
pub failed_requests: AtomicU64,
|
||||
pub dns_failures: AtomicU64,
|
||||
pub stream_errors: AtomicU64,
|
||||
}
|
||||
|
||||
impl ProxyMetrics {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
total_requests: AtomicU64::new(0),
|
||||
total_latency_ns: AtomicU64::new(0),
|
||||
failed_requests: AtomicU64::new(0),
|
||||
dns_failures: AtomicU64::new(0),
|
||||
stream_errors: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a completed request with its connection-establishment latency
|
||||
/// (DNS + TCP/TLS + TTFB, excludes response body streaming).
|
||||
pub fn record_request(&self, connect_elapsed: Duration) {
|
||||
let nanos = u64::try_from(connect_elapsed.as_nanos()).unwrap_or(u64::MAX);
|
||||
self.total_requests.fetch_add(1, Ordering::Release);
|
||||
self.total_latency_ns.fetch_add(nanos, Ordering::Release);
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Check if an IP address belongs to a private/reserved network.
|
||||
pub fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => is_private_ipv4(v4),
|
||||
IpAddr::V6(v6) => is_private_ipv6(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
|
||||
let octets = ip.octets();
|
||||
// 10.0.0.0/8
|
||||
if octets[0] == 10 {
|
||||
return true;
|
||||
}
|
||||
// 172.16.0.0/12
|
||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// 192.168.0.0/16
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return true;
|
||||
}
|
||||
// 127.0.0.0/8
|
||||
if octets[0] == 127 {
|
||||
return true;
|
||||
}
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return true;
|
||||
}
|
||||
// 0.0.0.0/8
|
||||
if octets[0] == 0 {
|
||||
return true;
|
||||
}
|
||||
// 100.64.0.0/10 (CGNAT / shared address space)
|
||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// 192.0.0.0/24 (IETF protocol assignments)
|
||||
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
|
||||
return true;
|
||||
}
|
||||
// 198.18.0.0/15 (benchmark testing)
|
||||
if octets[0] == 198 && (18..=19).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// 240.0.0.0/4 (reserved for future use)
|
||||
if octets[0] >= 240 {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
|
||||
// ::1 loopback
|
||||
if ip.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
// :: unspecified
|
||||
if ip.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 (ULA) - first byte is 0xfc or 0xfd
|
||||
if segments[0] & 0xfe00 == 0xfc00 {
|
||||
return true;
|
||||
}
|
||||
// fe80::/10 (link-local)
|
||||
if segments[0] & 0xffc0 == 0xfe80 {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return is_private_ipv4(&v4);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FilterError {
|
||||
PrivateIp(IpAddr),
|
||||
PortNotAllowed(u16),
|
||||
DnsResolutionFailed(String),
|
||||
NoPublicAddrs(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FilterError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::PrivateIp(ip) => write!(f, "target IP {} is in private/reserved range", ip),
|
||||
Self::PortNotAllowed(port) => write!(f, "port {} not in allowed list", port),
|
||||
Self::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for {}", host),
|
||||
Self::NoPublicAddrs(host) => {
|
||||
write!(
|
||||
f,
|
||||
"all resolved addresses for {} are private/reserved",
|
||||
host
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DnsCacheEntry {
|
||||
addrs: Arc<Vec<SocketAddr>>,
|
||||
expires_at: Instant,
|
||||
inserted_at: Instant,
|
||||
}
|
||||
|
||||
/// Lightweight DNS cache with TTL + capacity bounds.
|
||||
/// Stores all public resolved addresses per host (used by SafeDnsResolver
|
||||
/// to ensure reqwest connects to the same validated addresses).
|
||||
pub struct DnsCache {
|
||||
ttl: Duration,
|
||||
capacity: usize,
|
||||
entries: RwLock<HashMap<String, DnsCacheEntry>>,
|
||||
}
|
||||
|
||||
impl DnsCache {
|
||||
pub fn new(ttl: Duration, capacity: usize) -> Self {
|
||||
Self {
|
||||
ttl,
|
||||
capacity,
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up cached public addresses for a host (any port).
|
||||
///
|
||||
/// Used by `SafeDnsResolver` which only knows the hostname — returns the
|
||||
/// first unexpired entry whose key starts with `host:`.
|
||||
pub async fn get_by_host(&self, host: &str) -> Option<Arc<Vec<SocketAddr>>> {
|
||||
if self.capacity == 0 || self.ttl.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{}:", host.to_ascii_lowercase());
|
||||
let now = Instant::now();
|
||||
let entries = self.entries.read().await;
|
||||
for (key, entry) in entries.iter() {
|
||||
if key.starts_with(&prefix) && entry.expires_at > now {
|
||||
return Some(Arc::clone(&entry.addrs));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Look up cached public addresses for a host + port.
|
||||
pub async fn get(&self, host: &str, port: u16) -> Option<Arc<Vec<SocketAddr>>> {
|
||||
if self.capacity == 0 || self.ttl.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let key = Self::key(host, port);
|
||||
let now = Instant::now();
|
||||
|
||||
// Fast path: read lock for cache hit
|
||||
{
|
||||
let entries = self.entries.read().await;
|
||||
match entries.get(&key) {
|
||||
Some(entry) if entry.expires_at > now => return Some(Arc::clone(&entry.addrs)),
|
||||
None => return None,
|
||||
Some(_) => {} // expired, fall through to evict
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: write lock to remove expired entry
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.remove(&key);
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert resolved public addresses into cache.
|
||||
pub async fn insert(&self, host: &str, port: u16, addrs: Arc<Vec<SocketAddr>>) {
|
||||
if self.capacity == 0 || self.ttl.is_zero() || addrs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = Self::key(host, port);
|
||||
let now = Instant::now();
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.retain(|_, entry| entry.expires_at > now);
|
||||
while entries.len() >= self.capacity {
|
||||
let oldest_key = entries
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.inserted_at)
|
||||
.map(|(key, _)| key.clone());
|
||||
if let Some(key) = oldest_key {
|
||||
entries.remove(&key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
entries.insert(
|
||||
key,
|
||||
DnsCacheEntry {
|
||||
addrs,
|
||||
expires_at: now + self.ttl,
|
||||
inserted_at: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn key(host: &str, port: u16) -> String {
|
||||
format!("{}:{}", host.to_ascii_lowercase(), port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a hostname to public (non-private) socket addresses.
|
||||
///
|
||||
/// Results are cached in `dns_cache`. Private/reserved IPs are filtered out.
|
||||
/// Returns an error if no public addresses remain after filtering.
|
||||
pub async fn resolve_public_addrs(
|
||||
host: &str,
|
||||
port: u16,
|
||||
dns_cache: &DnsCache,
|
||||
) -> Result<Vec<SocketAddr>, FilterError> {
|
||||
// Cache hit
|
||||
if let Some(addrs) = dns_cache.get(host, port).await {
|
||||
return Ok((*addrs).clone());
|
||||
}
|
||||
|
||||
// Async DNS resolution
|
||||
let addr_str = format!("{}:{}", host, port);
|
||||
let resolved: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
|
||||
.await
|
||||
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
|
||||
.collect();
|
||||
|
||||
if resolved.is_empty() {
|
||||
return Err(FilterError::DnsResolutionFailed(host.to_string()));
|
||||
}
|
||||
|
||||
// Filter out private/reserved addresses
|
||||
let public: Vec<SocketAddr> = resolved
|
||||
.into_iter()
|
||||
.filter(|addr| !is_private_ip(&addr.ip()))
|
||||
.collect();
|
||||
|
||||
if public.is_empty() {
|
||||
return Err(FilterError::NoPublicAddrs(host.to_string()));
|
||||
}
|
||||
|
||||
// Cache the validated public addresses
|
||||
let arc_addrs = Arc::new(public);
|
||||
dns_cache.insert(host, port, Arc::clone(&arc_addrs)).await;
|
||||
Ok((*arc_addrs).clone())
|
||||
}
|
||||
|
||||
/// Validate that the target host:port is allowed.
|
||||
///
|
||||
/// Performs port whitelist check, private IP filtering, and DNS resolution
|
||||
/// with caching. The resolved addresses are stored in the shared DnsCache
|
||||
/// so that the SafeDnsResolver can reuse them, eliminating the TOCTTOU gap.
|
||||
pub async fn validate_target(
|
||||
host: &str,
|
||||
port: u16,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
dns_cache: &DnsCache,
|
||||
) -> Result<Vec<SocketAddr>, FilterError> {
|
||||
// Port whitelist check
|
||||
if !allowed_ports.contains(&port) {
|
||||
return Err(FilterError::PortNotAllowed(port));
|
||||
}
|
||||
|
||||
// Try parsing as IP directly (no DNS needed)
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_private_ip(&ip) {
|
||||
return Err(FilterError::PrivateIp(ip));
|
||||
}
|
||||
return Ok(vec![SocketAddr::new(ip, port)]);
|
||||
}
|
||||
|
||||
// Resolve and validate DNS (populates cache for SafeDnsResolver)
|
||||
resolve_public_addrs(host, port, dns_cache).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ports() -> HashSet<u16> {
|
||||
[80, 443, 8080, 8443].into_iter().collect()
|
||||
}
|
||||
|
||||
fn cache() -> DnsCache {
|
||||
DnsCache::new(Duration::from_secs(60), 128)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_ipv4() {
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
|
||||
// CGNAT
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(
|
||||
100, 127, 255, 254
|
||||
))));
|
||||
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(
|
||||
100, 63, 255, 254
|
||||
))));
|
||||
// Benchmark testing
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
|
||||
// Reserved
|
||||
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
|
||||
// Public
|
||||
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_ipv6() {
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
|
||||
// fc00::1 (ULA)
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0xfc00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
// fe80::1 (link-local)
|
||||
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_port_not_allowed() {
|
||||
let cache = cache();
|
||||
let result = validate_target("8.8.8.8", 22, &ports(), &cache).await;
|
||||
assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_private_ip_blocked() {
|
||||
let cache = cache();
|
||||
let result = validate_target("127.0.0.1", 80, &ports(), &cache).await;
|
||||
assert!(matches!(result, Err(FilterError::PrivateIp(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_public_ip_allowed() {
|
||||
let cache = cache();
|
||||
let result = validate_target("8.8.8.8", 443, &ports(), &cache).await;
|
||||
assert!(result.is_ok());
|
||||
let addrs = result.unwrap();
|
||||
assert_eq!(addrs.len(), 1);
|
||||
assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_stores_multiple_addrs() {
|
||||
let cache = cache();
|
||||
let addrs = vec![
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 0, 0, 1)), 443),
|
||||
];
|
||||
cache
|
||||
.insert("example.com", 443, Arc::new(addrs.clone()))
|
||||
.await;
|
||||
let cached = cache.get("example.com", 443).await.unwrap();
|
||||
assert_eq!(*cached, addrs);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_key_case_insensitive() {
|
||||
let cache = cache();
|
||||
let addrs = vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443)];
|
||||
cache
|
||||
.insert("Example.COM", 443, Arc::new(addrs.clone()))
|
||||
.await;
|
||||
let cached = cache.get("example.com", 443).await.unwrap();
|
||||
assert_eq!(*cached, addrs);
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
//! WebSocket tunnel client: connect, authenticate, and run the tunnel.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::watch;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http;
|
||||
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::state::{AppState, ServerContext};
|
||||
|
||||
use super::{dispatcher, heartbeat, writer};
|
||||
|
||||
/// Outcome of a tunnel session.
|
||||
pub enum TunnelOutcome {
|
||||
/// Graceful shutdown requested by the local process.
|
||||
Shutdown,
|
||||
/// Remote side disconnected or connection lost — should reconnect.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Connect to Aether's WebSocket tunnel endpoint and run until disconnected.
|
||||
///
|
||||
/// `conn_idx` identifies which connection in the pool this is (0-based).
|
||||
/// Only connection 0 sends heartbeats to avoid resetting shared metrics.
|
||||
pub async fn connect_and_run(
|
||||
state: &Arc<AppState>,
|
||||
server: &Arc<ServerContext>,
|
||||
conn_idx: usize,
|
||||
shutdown: &mut watch::Receiver<bool>,
|
||||
) -> Result<TunnelOutcome, anyhow::Error> {
|
||||
let ws_url = build_tunnel_url(server);
|
||||
info!(url = %ws_url, conn = conn_idx, "connecting tunnel");
|
||||
|
||||
// Build WebSocket request with auth headers
|
||||
let mut request = ws_url.clone().into_client_request()?;
|
||||
let headers = request.headers_mut();
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
http::HeaderValue::from_str(&format!("Bearer {}", server.management_token))?,
|
||||
);
|
||||
let node_id = server.node_id.read().unwrap().clone();
|
||||
headers.insert("X-Node-Id", http::HeaderValue::from_str(&node_id)?);
|
||||
// Use dynamic node_name (may be updated by remote config) instead of
|
||||
// the static server.node_name, so that remote name changes take effect
|
||||
// on the next reconnect.
|
||||
let dynamic_node_name = server.dynamic.load().node_name.clone();
|
||||
headers.insert(
|
||||
"X-Node-Name",
|
||||
http::HeaderValue::from_str(&dynamic_node_name)?,
|
||||
);
|
||||
// Advertise per-connection max concurrent streams so the backend can
|
||||
// respect the proxy's capacity limit (backward-compatible: old backends
|
||||
// ignore this header).
|
||||
let max_streams = state.config.tunnel_max_streams.unwrap_or(128);
|
||||
headers.insert("X-Tunnel-Max-Streams", http::HeaderValue::from(max_streams));
|
||||
|
||||
// Parse host:port from URL
|
||||
let uri: http::Uri = ws_url.parse()?;
|
||||
let host = uri
|
||||
.host()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing host in tunnel URL"))?;
|
||||
let is_tls = uri.scheme_str() == Some("wss");
|
||||
let port = uri.port_u16().unwrap_or(if is_tls { 443 } else { 80 });
|
||||
|
||||
// TCP connect with timeout
|
||||
let connect_timeout = Duration::from_secs(state.config.tunnel_connect_timeout_secs);
|
||||
let tcp_stream = tokio::time::timeout(connect_timeout, TcpStream::connect((host, port)))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"tunnel TCP connect timeout ({}s)",
|
||||
connect_timeout.as_secs()
|
||||
)
|
||||
})??;
|
||||
|
||||
// Configure TCP parameters via socket2
|
||||
configure_tcp_socket(&tcp_stream, state);
|
||||
|
||||
// WebSocket upgrade (with TLS if wss://)
|
||||
let connector = if is_tls {
|
||||
Some(tokio_tungstenite::Connector::Rustls(Arc::clone(
|
||||
&state.tunnel_tls_config,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Match Python-side _MAX_FRAME_SIZE (64 MiB) to prevent tungstenite's
|
||||
// default 16 MiB limit from rejecting large AI API payloads (multi-image
|
||||
// base64 requests can exceed 16 MiB).
|
||||
let ws_config = WebSocketConfig {
|
||||
max_frame_size: Some(64 << 20),
|
||||
max_message_size: Some(64 << 20),
|
||||
..Default::default()
|
||||
};
|
||||
let handshake_timeout = Duration::from_secs(state.config.tunnel_connect_timeout_secs);
|
||||
let (ws_stream, _response) = tokio::time::timeout(
|
||||
handshake_timeout,
|
||||
tokio_tungstenite::client_async_tls_with_config(
|
||||
request,
|
||||
tcp_stream,
|
||||
Some(ws_config),
|
||||
connector,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"tunnel WebSocket handshake timeout ({}s)",
|
||||
handshake_timeout.as_secs()
|
||||
)
|
||||
})??;
|
||||
info!(
|
||||
conn = conn_idx,
|
||||
tcp_keepalive_secs = state.config.tunnel_tcp_keepalive_secs,
|
||||
tcp_nodelay = state.config.tunnel_tcp_nodelay,
|
||||
connect_timeout_secs = state.config.tunnel_connect_timeout_secs,
|
||||
stale_timeout_secs = state.config.tunnel_stale_timeout_secs,
|
||||
"tunnel connected"
|
||||
);
|
||||
|
||||
// NOTE: reconnect_attempts reset is handled by the caller (mod.rs)
|
||||
// based on how long the connection stayed alive.
|
||||
|
||||
// Split into read/write halves
|
||||
let (ws_sink, ws_read) = futures_util::StreamExt::split(ws_stream);
|
||||
|
||||
// Spawn writer task (with WebSocket ping keepalive)
|
||||
let ping_interval = Duration::from_secs(state.config.tunnel_ping_interval_secs);
|
||||
let (frame_tx, mut writer_handle) = writer::spawn_writer(ws_sink, ping_interval);
|
||||
|
||||
// Spawn heartbeat task (only for primary connection to avoid
|
||||
// resetting shared atomic metrics via swap(0))
|
||||
let hb_handle = if conn_idx == 0 {
|
||||
heartbeat::spawn(
|
||||
Arc::clone(&state.config),
|
||||
Arc::clone(server),
|
||||
frame_tx.clone(),
|
||||
shutdown.clone(),
|
||||
)
|
||||
} else {
|
||||
heartbeat::spawn_noop()
|
||||
};
|
||||
|
||||
// Run dispatcher (blocks until disconnect or shutdown).
|
||||
// Also watch for writer exit — if the write half dies (e.g. the peer
|
||||
// closed the connection) but the read half stays open, dispatcher would
|
||||
// block forever on `ws_stream.next()`. Monitoring `writer_handle`
|
||||
// ensures we detect this and trigger a reconnect promptly.
|
||||
let state_clone = Arc::clone(state);
|
||||
let server_clone = Arc::clone(server);
|
||||
let outcome = tokio::select! {
|
||||
result = dispatcher::run(state_clone, server_clone, ws_read, frame_tx.clone(), hb_handle) => {
|
||||
match result {
|
||||
Ok(()) => TunnelOutcome::Disconnected,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
writer_result = &mut writer_handle => {
|
||||
match writer_result {
|
||||
Ok(()) => warn!("writer task exited normally, triggering reconnect"),
|
||||
Err(e) => {
|
||||
if e.is_panic() {
|
||||
tracing::error!(error = %e, "writer task panicked, triggering reconnect");
|
||||
} else {
|
||||
warn!(error = %e, "writer task cancelled, triggering reconnect");
|
||||
}
|
||||
}
|
||||
}
|
||||
TunnelOutcome::Disconnected
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
debug!("shutdown during tunnel dispatch");
|
||||
TunnelOutcome::Shutdown
|
||||
}
|
||||
};
|
||||
|
||||
// Drop our sender; the writer will exit once all stream handler clones
|
||||
// are also dropped (i.e. after they finish their in-flight work).
|
||||
drop(frame_tx);
|
||||
|
||||
// Wait for the writer task to finish with a generous timeout — the
|
||||
// dispatcher already waits up to 30s for stream handlers, so 35s here
|
||||
// covers that plus a small margin.
|
||||
// Skip if the writer already exited (the select branch that fired).
|
||||
if !writer_handle.is_finished() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(35), writer_handle).await;
|
||||
}
|
||||
|
||||
info!("tunnel disconnected");
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Configure TCP keepalive and NODELAY on an established socket.
|
||||
fn configure_tcp_socket(stream: &TcpStream, state: &Arc<AppState>) {
|
||||
let sock_ref = socket2::SockRef::from(stream);
|
||||
|
||||
if state.config.tunnel_tcp_keepalive_secs > 0 {
|
||||
let keepalive = socket2::TcpKeepalive::new()
|
||||
.with_time(Duration::from_secs(state.config.tunnel_tcp_keepalive_secs))
|
||||
.with_interval(Duration::from_secs(5));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let keepalive = keepalive.with_retries(3);
|
||||
if let Err(e) = sock_ref.set_tcp_keepalive(&keepalive) {
|
||||
warn!(error = %e, "failed to set TCP keepalive on tunnel socket");
|
||||
}
|
||||
}
|
||||
|
||||
if state.config.tunnel_tcp_nodelay {
|
||||
if let Err(e) = sock_ref.set_nodelay(true) {
|
||||
warn!(error = %e, "failed to set TCP_NODELAY on tunnel socket");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build rustls ClientConfig with system root certificates.
|
||||
pub fn build_tls_config() -> rustls::ClientConfig {
|
||||
let root_store =
|
||||
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth()
|
||||
}
|
||||
|
||||
fn build_tunnel_url(server: &ServerContext) -> String {
|
||||
let base = server.aether_url.trim_end_matches('/');
|
||||
let ws_base = if base.starts_with("https://") {
|
||||
base.replacen("https://", "wss://", 1)
|
||||
} else if base.starts_with("http://") {
|
||||
base.replacen("http://", "ws://", 1)
|
||||
} else {
|
||||
format!("wss://{}", base)
|
||||
};
|
||||
format!("{}/api/internal/proxy-tunnel", ws_base)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
//! Frame dispatcher: reads incoming WebSocket frames and routes them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::state::{AppState, ServerContext};
|
||||
|
||||
use super::heartbeat::HeartbeatHandle;
|
||||
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
|
||||
use super::stream_handler;
|
||||
use super::writer::FrameSender;
|
||||
|
||||
/// Run the dispatcher loop, reading from the WebSocket stream.
|
||||
pub async fn run<S>(
|
||||
state: Arc<AppState>,
|
||||
server: Arc<ServerContext>,
|
||||
mut ws_stream: S,
|
||||
frame_tx: FrameSender,
|
||||
heartbeat: HeartbeatHandle,
|
||||
) -> Result<(), anyhow::Error>
|
||||
where
|
||||
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
|
||||
+ Unpin
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
// Active streams: stream_id -> body sender
|
||||
let mut streams: HashMap<u32, mpsc::Sender<Frame>> = HashMap::new();
|
||||
// Track spawned stream handlers so we can wait for them on shutdown
|
||||
let mut handler_handles: Vec<JoinHandle<()>> = Vec::new();
|
||||
let max_streams = state.config.tunnel_max_streams.unwrap_or(128) as usize;
|
||||
let mut frames_since_cleanup: u32 = 0;
|
||||
let stale_timeout = Duration::from_secs(state.config.tunnel_stale_timeout_secs);
|
||||
|
||||
// Track last time we received any data to detect stale connections
|
||||
let mut last_data_at = tokio::time::Instant::now();
|
||||
|
||||
let read_err = loop {
|
||||
let msg_result = tokio::select! {
|
||||
msg = ws_stream.next() => {
|
||||
match msg {
|
||||
Some(r) => r,
|
||||
None => break None,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(last_data_at + stale_timeout) => {
|
||||
warn!(
|
||||
stale_secs = stale_timeout.as_secs(),
|
||||
"tunnel connection stale, no data received"
|
||||
);
|
||||
break None;
|
||||
}
|
||||
};
|
||||
|
||||
let msg = match msg_result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!(error = %e, "WebSocket read error");
|
||||
break Some(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Any successfully received message proves the connection is alive
|
||||
last_data_at = tokio::time::Instant::now();
|
||||
|
||||
let data = match msg {
|
||||
Message::Binary(data) => Bytes::from(data),
|
||||
Message::Ping(_) => continue,
|
||||
Message::Pong(_) => continue,
|
||||
Message::Close(_) => {
|
||||
info!("received WebSocket close");
|
||||
break None;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let frame = match Frame::decode(data) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to decode frame");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match frame.msg_type {
|
||||
MsgType::RequestHeaders => {
|
||||
// Decompress if the frame is gzip-compressed, then parse metadata
|
||||
let payload = match decompress_if_gzip(&frame) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(stream_id = frame.stream_id, error = %e, "frame decompress failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let meta: RequestMeta = match serde_json::from_slice(&payload) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata");
|
||||
// Use try_send to avoid blocking the read loop
|
||||
if frame_tx
|
||||
.try_send(Frame::new(
|
||||
frame.stream_id,
|
||||
MsgType::StreamError,
|
||||
0,
|
||||
Bytes::from(format!("invalid request metadata: {e}")),
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
stream_id = frame.stream_id,
|
||||
"writer channel full, StreamError dropped"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if streams.len() >= max_streams {
|
||||
warn!(
|
||||
stream_id = frame.stream_id,
|
||||
"max concurrent streams reached"
|
||||
);
|
||||
if frame_tx
|
||||
.try_send(Frame::new(
|
||||
frame.stream_id,
|
||||
MsgType::StreamError,
|
||||
0,
|
||||
Bytes::from("max concurrent streams reached"),
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
stream_id = frame.stream_id,
|
||||
"writer channel full, StreamError dropped"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create body channel and spawn handler
|
||||
let (body_tx, body_rx) = mpsc::channel::<Frame>(64);
|
||||
streams.insert(frame.stream_id, body_tx);
|
||||
|
||||
let state_clone = Arc::clone(&state);
|
||||
let server_clone = Arc::clone(&server);
|
||||
let tx_clone = frame_tx.clone();
|
||||
let sid = frame.stream_id;
|
||||
let handle = tokio::spawn(async move {
|
||||
stream_handler::handle_stream(
|
||||
state_clone,
|
||||
server_clone,
|
||||
sid,
|
||||
meta,
|
||||
body_rx,
|
||||
tx_clone,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
handler_handles.push(handle);
|
||||
|
||||
debug!(stream_id = frame.stream_id, "new stream started");
|
||||
}
|
||||
|
||||
MsgType::RequestBody => {
|
||||
if let Some(tx) = streams.get(&frame.stream_id) {
|
||||
let is_end = frame.is_end_stream();
|
||||
let sid = frame.stream_id;
|
||||
let _ = tx.send(frame).await;
|
||||
if is_end {
|
||||
streams.remove(&sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MsgType::StreamEnd | MsgType::StreamError => {
|
||||
// Client-side cancellation or end
|
||||
streams.remove(&frame.stream_id);
|
||||
}
|
||||
|
||||
MsgType::Ping => {
|
||||
// Use try_send to avoid blocking the read loop when writer is congested
|
||||
if frame_tx
|
||||
.try_send(Frame::control(MsgType::Pong, frame.payload))
|
||||
.is_err()
|
||||
{
|
||||
warn!("writer channel full, Pong dropped");
|
||||
}
|
||||
}
|
||||
|
||||
MsgType::HeartbeatAck => {
|
||||
heartbeat.on_ack(frame.payload).await;
|
||||
}
|
||||
|
||||
MsgType::GoAway => {
|
||||
info!("received GOAWAY");
|
||||
break None;
|
||||
}
|
||||
|
||||
_ => {
|
||||
debug!(msg_type = ?frame.msg_type, "ignoring unexpected frame type");
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically clean up finished handles to avoid unbounded growth.
|
||||
// Trigger every 64 frames OR when the count exceeds max_streams.
|
||||
frames_since_cleanup += 1;
|
||||
if frames_since_cleanup >= 64 || handler_handles.len() > max_streams {
|
||||
handler_handles.retain(|h| !h.is_finished());
|
||||
frames_since_cleanup = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Drop body senders so stream handlers waiting on body_rx will unblock
|
||||
streams.clear();
|
||||
|
||||
// Wait for active stream handlers to finish so their frame_tx clones
|
||||
// are dropped before the writer closes the sink.
|
||||
drain_handlers(handler_handles).await;
|
||||
|
||||
match read_err {
|
||||
Some(e) => Err(e.into()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for all active stream handlers to finish (with a timeout).
|
||||
async fn drain_handlers(handles: Vec<JoinHandle<()>>) {
|
||||
if handles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let count = handles.len();
|
||||
debug!(count, "waiting for active stream handlers to finish");
|
||||
let _ = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
//! Tunnel heartbeat: sends metrics over the tunnel, processes ACKs.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::registration::client::RemoteConfig;
|
||||
use crate::runtime;
|
||||
use crate::state::ServerContext;
|
||||
|
||||
use super::protocol::{Frame, MsgType};
|
||||
use super::writer::FrameSender;
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
static UPGRADE_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||
static NON_ROOT_UPGRADE_WARNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
enum AckDecision {
|
||||
Accept {
|
||||
heartbeat_id: Option<u64>,
|
||||
upgrade_to: Option<String>,
|
||||
},
|
||||
Ignore,
|
||||
}
|
||||
|
||||
/// Handle for the dispatcher to forward HeartbeatAck frames.
|
||||
#[derive(Clone)]
|
||||
pub struct HeartbeatHandle {
|
||||
ack_tx: tokio::sync::mpsc::Sender<Bytes>,
|
||||
}
|
||||
|
||||
impl HeartbeatHandle {
|
||||
pub async fn on_ack(&self, payload: Bytes) {
|
||||
let _ = self.ack_tx.send(payload).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a no-op heartbeat handle that silently discards ACKs.
|
||||
/// Used for non-primary tunnel connections (conn_idx > 0) to avoid
|
||||
/// resetting shared atomic metrics via `swap(0)`.
|
||||
pub fn spawn_noop() -> HeartbeatHandle {
|
||||
let (ack_tx, _) = tokio::sync::mpsc::channel::<Bytes>(1);
|
||||
// receiver is immediately dropped; on_ack() calls will silently fail
|
||||
HeartbeatHandle { ack_tx }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct HeartbeatSnapshot {
|
||||
requests: u64,
|
||||
latency_ns: u64,
|
||||
failed: u64,
|
||||
dns_failures: u64,
|
||||
stream_errors: u64,
|
||||
}
|
||||
|
||||
/// Spawn the heartbeat task. Returns a handle for forwarding ACKs.
|
||||
pub fn spawn(
|
||||
_config: Arc<Config>,
|
||||
server: Arc<ServerContext>,
|
||||
frame_tx: FrameSender,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> HeartbeatHandle {
|
||||
let (ack_tx, mut ack_rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Read initial interval from dynamic config (may be updated by remote config).
|
||||
let initial_interval = Duration::from_secs(server.dynamic.load().heartbeat_interval);
|
||||
let mut current_interval = initial_interval;
|
||||
// At most one in-flight heartbeat snapshot is tracked at a time.
|
||||
// Snapshot is only cleared after receiving an ACK, which avoids losing
|
||||
// interval counters when ACK/frame delivery is temporarily unstable.
|
||||
let mut pending: Option<(u64, HeartbeatSnapshot)> = None;
|
||||
let mut next_heartbeat_id: u64 = 1;
|
||||
let heartbeat_session_id = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
|
||||
// Skip first immediate tick by sleeping first.
|
||||
tokio::time::sleep(current_interval).await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(current_interval) => {
|
||||
let (heartbeat_id, snapshot) = if let Some((id, snap)) = pending {
|
||||
(id, snap)
|
||||
} else {
|
||||
let snap = collect_snapshot(&server);
|
||||
let id = next_heartbeat_id;
|
||||
next_heartbeat_id = next_heartbeat_id.wrapping_add(1);
|
||||
if next_heartbeat_id == 0 {
|
||||
next_heartbeat_id = 1;
|
||||
}
|
||||
pending = Some((id, snap));
|
||||
(id, snap)
|
||||
};
|
||||
|
||||
let payload = build_heartbeat_payload(
|
||||
&server,
|
||||
&heartbeat_session_id,
|
||||
heartbeat_id,
|
||||
snapshot
|
||||
);
|
||||
let frame = Frame::control(MsgType::HeartbeatData, payload);
|
||||
if frame_tx.send(frame).await.is_err() {
|
||||
if let Some((_, snap)) = pending.take() {
|
||||
restore_snapshot(&server, snap);
|
||||
}
|
||||
break; // Writer closed
|
||||
}
|
||||
debug!("sent heartbeat data");
|
||||
|
||||
// Re-read interval from dynamic config (remote config may have
|
||||
// updated it since the last heartbeat).
|
||||
let new_interval = Duration::from_secs(
|
||||
server.dynamic.load().heartbeat_interval
|
||||
);
|
||||
if new_interval != current_interval {
|
||||
debug!(
|
||||
old_secs = current_interval.as_secs(),
|
||||
new_secs = new_interval.as_secs(),
|
||||
"heartbeat interval updated from dynamic config"
|
||||
);
|
||||
current_interval = new_interval;
|
||||
}
|
||||
}
|
||||
Some(ack_payload) = ack_rx.recv() => {
|
||||
match handle_ack(&server, &ack_payload) {
|
||||
AckDecision::Accept {
|
||||
heartbeat_id: ack_id,
|
||||
upgrade_to,
|
||||
} => {
|
||||
if let Some((pending_id, _)) = pending {
|
||||
match ack_id {
|
||||
Some(id) if id == pending_id => {
|
||||
pending = None;
|
||||
}
|
||||
None => {
|
||||
// Backward-compatible with servers that don't echo
|
||||
// heartbeat_id in ACK payload yet.
|
||||
pending = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
maybe_trigger_upgrade(upgrade_to);
|
||||
}
|
||||
AckDecision::Ignore => {}
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
debug!("heartbeat task shutting down");
|
||||
if let Some((_, snap)) = pending.take() {
|
||||
restore_snapshot(&server, snap);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
HeartbeatHandle { ack_tx }
|
||||
}
|
||||
|
||||
fn collect_snapshot(server: &ServerContext) -> HeartbeatSnapshot {
|
||||
HeartbeatSnapshot {
|
||||
requests: server.metrics.total_requests.swap(0, Ordering::AcqRel),
|
||||
latency_ns: server.metrics.total_latency_ns.swap(0, Ordering::AcqRel),
|
||||
failed: server.metrics.failed_requests.swap(0, Ordering::AcqRel),
|
||||
dns_failures: server.metrics.dns_failures.swap(0, Ordering::AcqRel),
|
||||
stream_errors: server.metrics.stream_errors.swap(0, Ordering::AcqRel),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_snapshot(server: &ServerContext, snap: HeartbeatSnapshot) {
|
||||
if snap.requests > 0 {
|
||||
server
|
||||
.metrics
|
||||
.total_requests
|
||||
.fetch_add(snap.requests, Ordering::Release);
|
||||
}
|
||||
if snap.latency_ns > 0 {
|
||||
server
|
||||
.metrics
|
||||
.total_latency_ns
|
||||
.fetch_add(snap.latency_ns, Ordering::Release);
|
||||
}
|
||||
if snap.failed > 0 {
|
||||
server
|
||||
.metrics
|
||||
.failed_requests
|
||||
.fetch_add(snap.failed, Ordering::Release);
|
||||
}
|
||||
if snap.dns_failures > 0 {
|
||||
server
|
||||
.metrics
|
||||
.dns_failures
|
||||
.fetch_add(snap.dns_failures, Ordering::Release);
|
||||
}
|
||||
if snap.stream_errors > 0 {
|
||||
server
|
||||
.metrics
|
||||
.stream_errors
|
||||
.fetch_add(snap.stream_errors, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_heartbeat_payload(
|
||||
server: &ServerContext,
|
||||
heartbeat_session_id: &str,
|
||||
heartbeat_id: u64,
|
||||
snapshot: HeartbeatSnapshot,
|
||||
) -> Bytes {
|
||||
let node_id = server.node_id.read().unwrap().clone();
|
||||
|
||||
let avg_latency_ms = if snapshot.requests > 0 {
|
||||
Some(snapshot.latency_ns as f64 / snapshot.requests as f64 / 1_000_000.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"heartbeat_session_id": heartbeat_session_id,
|
||||
"heartbeat_id": heartbeat_id,
|
||||
"active_connections": server.active_connections.load(Ordering::Acquire),
|
||||
"total_requests": snapshot.requests,
|
||||
"avg_latency_ms": avg_latency_ms,
|
||||
"failed_requests": snapshot.failed,
|
||||
"dns_failures": snapshot.dns_failures,
|
||||
"stream_errors": snapshot.stream_errors,
|
||||
"proxy_metadata": {
|
||||
"version": CURRENT_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn handle_ack(server: &ServerContext, payload: &[u8]) -> AckDecision {
|
||||
if payload.is_empty() {
|
||||
return AckDecision::Accept {
|
||||
heartbeat_id: None,
|
||||
upgrade_to: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AckPayload {
|
||||
#[serde(default)]
|
||||
remote_config: Option<RemoteConfig>,
|
||||
#[serde(default)]
|
||||
config_version: u64,
|
||||
#[serde(default)]
|
||||
heartbeat_id: Option<u64>,
|
||||
#[serde(default)]
|
||||
upgrade_to: Option<String>,
|
||||
}
|
||||
|
||||
match serde_json::from_slice::<AckPayload>(payload) {
|
||||
Ok(ack) => {
|
||||
if let Some(ref rc) = ack.remote_config {
|
||||
runtime::apply_remote_config(&server.dynamic, rc, ack.config_version);
|
||||
}
|
||||
AckDecision::Accept {
|
||||
heartbeat_id: ack.heartbeat_id,
|
||||
upgrade_to: ack.upgrade_to.and_then(normalize_upgrade_target),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to parse heartbeat ACK");
|
||||
AckDecision::Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_upgrade_target(raw: String) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let normalized = trimmed.strip_prefix("proxy-v").unwrap_or(trimmed);
|
||||
if normalized == CURRENT_VERSION {
|
||||
return None;
|
||||
}
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
|
||||
fn maybe_trigger_upgrade(version: Option<String>) {
|
||||
let Some(target_version) = version else {
|
||||
return;
|
||||
};
|
||||
if !crate::setup::service::is_root() {
|
||||
if NON_ROOT_UPGRADE_WARNED
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
warn!(
|
||||
target_version = %target_version,
|
||||
"remote upgrade skipped: root privileges are required"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if UPGRADE_IN_PROGRESS
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
debug!(target_version = %target_version, "upgrade already in progress, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
info!(target_version = %target_version, "received remote upgrade instruction");
|
||||
match crate::setup::upgrade::perform_upgrade(&target_version).await {
|
||||
Ok(()) => {
|
||||
info!(target_version = %target_version, "remote upgrade finished");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target_version = %target_version,
|
||||
error = %e,
|
||||
"remote upgrade failed"
|
||||
);
|
||||
UPGRADE_IN_PROGRESS.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
pub mod client;
|
||||
pub mod dispatcher;
|
||||
pub mod heartbeat;
|
||||
pub mod protocol;
|
||||
pub mod stream_handler;
|
||||
pub mod writer;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::state::{AppState, ServerContext};
|
||||
|
||||
/// If a tunnel stays connected at least this long, treat the next disconnect
|
||||
/// as a non-failure and reset reconnect backoff.
|
||||
const STABLE_SESSION_RESET_AFTER: Duration = Duration::from_secs(30);
|
||||
/// Startup staggering step per secondary connection, used to avoid
|
||||
/// simultaneous bursts when a pool of tunnels starts together.
|
||||
const STARTUP_STAGGER_STEP_MS: u64 = 150;
|
||||
/// Upper bound for startup staggering.
|
||||
const MAX_STARTUP_STAGGER_MS: u64 = 1_500;
|
||||
/// Keep a tiny floor for repeated reconnects; first retry is still immediate.
|
||||
const MIN_RECONNECT_DELAY_MS: u64 = 50;
|
||||
/// Even under sustained failures, keep probing frequently so recovery is fast
|
||||
/// once cross-border network quality improves.
|
||||
const RECONNECT_PROBE_MAX_DELAY_MS: u64 = 3_000;
|
||||
|
||||
/// Run the tunnel mode main loop (connect, dispatch, reconnect).
|
||||
///
|
||||
/// `conn_idx` identifies which connection in the pool this is (0-based).
|
||||
/// Only connection 0 sends heartbeats to avoid resetting shared metrics.
|
||||
pub async fn run(
|
||||
state: &Arc<AppState>,
|
||||
server: &Arc<ServerContext>,
|
||||
conn_idx: usize,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
info!(server = %server.server_label, conn = conn_idx, "starting tunnel");
|
||||
let reconnect_salt = compute_connection_salt(server, conn_idx);
|
||||
|
||||
let startup_delay = compute_startup_stagger(conn_idx, reconnect_salt);
|
||||
if !startup_delay.is_zero() {
|
||||
info!(
|
||||
server = %server.server_label,
|
||||
conn = conn_idx,
|
||||
delay_ms = startup_delay.as_millis(),
|
||||
"startup stagger before first connect"
|
||||
);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(startup_delay) => {}
|
||||
_ = shutdown.changed() => {
|
||||
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during startup stagger");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
|
||||
loop {
|
||||
let started_at = Instant::now();
|
||||
match client::connect_and_run(state, server, conn_idx, &mut shutdown).await {
|
||||
Ok(client::TunnelOutcome::Shutdown) => {
|
||||
info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully");
|
||||
return;
|
||||
}
|
||||
Ok(client::TunnelOutcome::Disconnected) => {
|
||||
info!(server = %server.server_label, conn = conn_idx, "tunnel disconnected, reconnecting");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(server = %server.server_label, conn = conn_idx, error = %e, "tunnel connection error, reconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
if *shutdown.borrow() {
|
||||
info!(server = %server.server_label, conn = conn_idx, "shutdown requested, not reconnecting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset backoff after a stable session to keep recovery snappy when
|
||||
// failures are only occasional.
|
||||
let connected_for = started_at.elapsed();
|
||||
if connected_for >= STABLE_SESSION_RESET_AFTER {
|
||||
consecutive_failures = 0;
|
||||
} else {
|
||||
consecutive_failures = consecutive_failures.saturating_add(1);
|
||||
}
|
||||
|
||||
let reconnect_delay = compute_reconnect_delay(
|
||||
state.config.tunnel_reconnect_base_ms,
|
||||
state.config.tunnel_reconnect_max_ms,
|
||||
consecutive_failures,
|
||||
reconnect_salt,
|
||||
);
|
||||
info!(
|
||||
server = %server.server_label,
|
||||
conn = conn_idx,
|
||||
failures = consecutive_failures,
|
||||
delay_ms = reconnect_delay.as_millis(),
|
||||
"waiting before reconnect"
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(reconnect_delay) => {}
|
||||
_ = shutdown.changed() => {
|
||||
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_connection_salt(server: &ServerContext, conn_idx: usize) -> u64 {
|
||||
// FNV-1a style hash over server label + connection index.
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for &b in server.server_label.as_bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h ^= conn_idx as u64;
|
||||
mix_u64(h)
|
||||
}
|
||||
|
||||
fn compute_startup_stagger(conn_idx: usize, salt: u64) -> Duration {
|
||||
if conn_idx == 0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let base = (conn_idx as u64).saturating_mul(STARTUP_STAGGER_STEP_MS);
|
||||
let jitter = mix_u64(salt) % 301; // 0..=300ms
|
||||
Duration::from_millis((base + jitter).min(MAX_STARTUP_STAGGER_MS))
|
||||
}
|
||||
|
||||
fn compute_reconnect_delay(
|
||||
base_ms: u64,
|
||||
max_ms: u64,
|
||||
consecutive_failures: u32,
|
||||
salt: u64,
|
||||
) -> Duration {
|
||||
// First retry should be immediate to maximize recovery speed on transient
|
||||
// blips (the user's primary expectation in poor networks).
|
||||
if consecutive_failures <= 1 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
|
||||
// Keep a sane minimum for repeated failures.
|
||||
let base_ms = base_ms.max(MIN_RECONNECT_DELAY_MS);
|
||||
let max_ms = max_ms.max(base_ms);
|
||||
let cap_ms = compute_reconnect_cap_ms(base_ms, max_ms, consecutive_failures)
|
||||
.min(RECONNECT_PROBE_MAX_DELAY_MS.max(base_ms));
|
||||
|
||||
// Equal-jitter: randomize in [cap/2, cap], preventing synchronized reconnect
|
||||
// storms while keeping reconnect latency bounded.
|
||||
if cap_ms <= 1 {
|
||||
return Duration::from_millis(cap_ms);
|
||||
}
|
||||
|
||||
let half = cap_ms / 2;
|
||||
let span = cap_ms - half;
|
||||
let now_nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let mixed = mix_u64(now_nanos ^ salt);
|
||||
let jitter = if span == 0 { 0 } else { mixed % (span + 1) };
|
||||
Duration::from_millis(half + jitter)
|
||||
}
|
||||
|
||||
fn compute_reconnect_cap_ms(base_ms: u64, max_ms: u64, consecutive_failures: u32) -> u64 {
|
||||
if consecutive_failures <= 1 {
|
||||
return base_ms.min(max_ms);
|
||||
}
|
||||
|
||||
let shift = (consecutive_failures - 1).min(31);
|
||||
let factor = 1u64 << shift;
|
||||
base_ms.saturating_mul(factor).min(max_ms)
|
||||
}
|
||||
|
||||
fn mix_u64(mut x: u64) -> u64 {
|
||||
// SplitMix64 finalizer - cheap bit mixing for pseudo-random jitter.
|
||||
x ^= x >> 30;
|
||||
x = x.wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
x ^= x >> 27;
|
||||
x = x.wrapping_mul(0x94d049bb133111eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{
|
||||
compute_reconnect_cap_ms, compute_reconnect_delay, compute_startup_stagger,
|
||||
MAX_STARTUP_STAGGER_MS, RECONNECT_PROBE_MAX_DELAY_MS, STARTUP_STAGGER_STEP_MS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn reconnect_cap_grows_exponentially_and_caps() {
|
||||
let base = 500;
|
||||
let max = 30_000;
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 0), 500);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 1), 500);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 2), 1_000);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 3), 2_000);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 4), 4_000);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 5), 8_000);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 6), 16_000);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 7), 30_000);
|
||||
assert_eq!(compute_reconnect_cap_ms(base, max, 20), 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_stagger_is_zero_for_primary_and_bounded_for_secondary() {
|
||||
assert_eq!(compute_startup_stagger(0, 42), Duration::ZERO);
|
||||
|
||||
let d1 = compute_startup_stagger(1, 42);
|
||||
let d2 = compute_startup_stagger(2, 42);
|
||||
|
||||
assert!(d1 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS));
|
||||
assert!(d1 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
|
||||
assert!(d2 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS * 2));
|
||||
assert!(d2 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_delay_is_immediate_on_first_failure() {
|
||||
assert_eq!(compute_reconnect_delay(700, 45_000, 1, 123), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_delay_stays_within_probe_ceiling_after_many_failures() {
|
||||
let d = compute_reconnect_delay(500, 45_000, 100, 12345);
|
||||
assert!(d <= Duration::from_millis(RECONNECT_PROBE_MAX_DELAY_MS));
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
//! Binary frame protocol for WebSocket tunnel multiplexing.
|
||||
//!
|
||||
//! Frame layout (10-byte header + variable payload):
|
||||
//! ```text
|
||||
//! | stream_id (4B) | msg_type (1B) | flags (1B) | payload_len (4B) | payload (NB) |
|
||||
//! ```
|
||||
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
|
||||
pub const HEADER_SIZE: usize = 10;
|
||||
|
||||
/// Frame flags.
|
||||
pub mod flags {
|
||||
pub const END_STREAM: u8 = 0x01;
|
||||
pub const GZIP_COMPRESSED: u8 = 0x02;
|
||||
}
|
||||
|
||||
/// Message types for the tunnel protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum MsgType {
|
||||
RequestHeaders = 0x01,
|
||||
RequestBody = 0x02,
|
||||
ResponseHeaders = 0x03,
|
||||
ResponseBody = 0x04,
|
||||
StreamEnd = 0x05,
|
||||
StreamError = 0x06,
|
||||
Ping = 0x10,
|
||||
Pong = 0x11,
|
||||
GoAway = 0x12,
|
||||
HeartbeatData = 0x13,
|
||||
HeartbeatAck = 0x14,
|
||||
}
|
||||
|
||||
impl MsgType {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0x01 => Some(Self::RequestHeaders),
|
||||
0x02 => Some(Self::RequestBody),
|
||||
0x03 => Some(Self::ResponseHeaders),
|
||||
0x04 => Some(Self::ResponseBody),
|
||||
0x05 => Some(Self::StreamEnd),
|
||||
0x06 => Some(Self::StreamError),
|
||||
0x10 => Some(Self::Ping),
|
||||
0x11 => Some(Self::Pong),
|
||||
0x12 => Some(Self::GoAway),
|
||||
0x13 => Some(Self::HeartbeatData),
|
||||
0x14 => Some(Self::HeartbeatAck),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single multiplexed frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
pub stream_id: u32,
|
||||
pub msg_type: MsgType,
|
||||
pub flags: u8,
|
||||
pub payload: Bytes,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(stream_id: u32, msg_type: MsgType, flags: u8, payload: impl Into<Bytes>) -> Self {
|
||||
Self {
|
||||
stream_id,
|
||||
msg_type,
|
||||
flags,
|
||||
payload: payload.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Control frame (stream_id = 0).
|
||||
pub fn control(msg_type: MsgType, payload: impl Into<Bytes>) -> Self {
|
||||
Self::new(0, msg_type, 0, payload)
|
||||
}
|
||||
|
||||
pub fn is_end_stream(&self) -> bool {
|
||||
self.flags & flags::END_STREAM != 0
|
||||
}
|
||||
|
||||
pub fn is_gzip(&self) -> bool {
|
||||
self.flags & flags::GZIP_COMPRESSED != 0
|
||||
}
|
||||
|
||||
/// Encode into a binary buffer.
|
||||
pub fn encode(&self) -> Bytes {
|
||||
let mut buf = BytesMut::with_capacity(HEADER_SIZE + self.payload.len());
|
||||
buf.put_u32(self.stream_id);
|
||||
buf.put_u8(self.msg_type as u8);
|
||||
buf.put_u8(self.flags);
|
||||
buf.put_u32(self.payload.len() as u32);
|
||||
buf.put(self.payload.clone());
|
||||
buf.freeze()
|
||||
}
|
||||
|
||||
/// Decode from a binary buffer.
|
||||
pub fn decode(mut data: Bytes) -> Result<Self, ProtocolError> {
|
||||
if data.len() < HEADER_SIZE {
|
||||
return Err(ProtocolError::TooShort {
|
||||
expected: HEADER_SIZE,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let stream_id = data.get_u32();
|
||||
let msg_type_raw = data.get_u8();
|
||||
let frame_flags = data.get_u8();
|
||||
let payload_len = data.get_u32() as usize;
|
||||
|
||||
if data.remaining() < payload_len {
|
||||
return Err(ProtocolError::Incomplete {
|
||||
expected: HEADER_SIZE + payload_len,
|
||||
actual: HEADER_SIZE + data.remaining(),
|
||||
});
|
||||
}
|
||||
|
||||
let msg_type =
|
||||
MsgType::from_u8(msg_type_raw).ok_or(ProtocolError::UnknownMsgType(msg_type_raw))?;
|
||||
let payload = data.split_to(payload_len);
|
||||
|
||||
Ok(Self {
|
||||
stream_id,
|
||||
msg_type,
|
||||
flags: frame_flags,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProtocolError {
|
||||
#[error("frame too short: expected {expected} bytes, got {actual}")]
|
||||
TooShort { expected: usize, actual: usize },
|
||||
#[error("frame incomplete: expected {expected} bytes, got {actual}")]
|
||||
Incomplete { expected: usize, actual: usize },
|
||||
#[error("unknown message type: 0x{0:02x}")]
|
||||
UnknownMsgType(u8),
|
||||
}
|
||||
|
||||
/// JSON payload for REQUEST_HEADERS frames.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct RequestMeta {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
#[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn deserialize_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum TimeoutValue {
|
||||
Int(u64),
|
||||
Float(f64),
|
||||
}
|
||||
|
||||
match <TimeoutValue as serde::Deserialize>::deserialize(deserializer)? {
|
||||
TimeoutValue::Int(v) => Ok(v),
|
||||
TimeoutValue::Float(v) => {
|
||||
if !v.is_finite() || v < 0.0 {
|
||||
return Err(serde::de::Error::custom(
|
||||
"timeout must be a non-negative finite number",
|
||||
));
|
||||
}
|
||||
if v.fract() != 0.0 {
|
||||
return Err(serde::de::Error::custom("timeout must be integer seconds"));
|
||||
}
|
||||
if v > (u64::MAX as f64) {
|
||||
return Err(serde::de::Error::custom("timeout is too large"));
|
||||
}
|
||||
Ok(v as u64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON payload for RESPONSE_HEADERS frames.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ResponseMeta {
|
||||
pub status: u16,
|
||||
/// Header list preserving duplicates (e.g. multiple Set-Cookie).
|
||||
pub headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tunnel frame compression helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum payload size to attempt gzip compression (bytes).
|
||||
const COMPRESS_MIN_SIZE: usize = 512;
|
||||
|
||||
/// If the frame has the GZIP_COMPRESSED flag, decompress the payload; otherwise
|
||||
/// return a clone of the raw payload bytes.
|
||||
pub fn decompress_if_gzip(frame: &Frame) -> Result<Bytes, std::io::Error> {
|
||||
if frame.is_gzip() {
|
||||
decompress_gzip(&frame.payload)
|
||||
} else {
|
||||
Ok(frame.payload.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Gzip-compress `data` if it is large enough and compression actually shrinks
|
||||
/// the payload. Returns `(payload, extra_flags)` where `extra_flags` contains
|
||||
/// `GZIP_COMPRESSED` when compression was applied.
|
||||
pub fn compress_payload(data: Bytes) -> (Bytes, u8) {
|
||||
if data.len() >= COMPRESS_MIN_SIZE {
|
||||
if let Ok(compressed) = compress_gzip(&data) {
|
||||
if compressed.len() < data.len() {
|
||||
return (compressed, flags::GZIP_COMPRESSED);
|
||||
}
|
||||
}
|
||||
}
|
||||
(data, 0)
|
||||
}
|
||||
|
||||
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
|
||||
use flate2::read::GzDecoder;
|
||||
use std::io::Read;
|
||||
let mut decoder = GzDecoder::new(data);
|
||||
let mut buf = Vec::new();
|
||||
decoder.read_to_end(&mut buf)?;
|
||||
Ok(Bytes::from(buf))
|
||||
}
|
||||
|
||||
fn compress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::Write;
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
|
||||
encoder.write_all(data)?;
|
||||
let compressed = encoder.finish()?;
|
||||
Ok(Bytes::from(compressed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RequestMeta;
|
||||
|
||||
#[test]
|
||||
fn request_meta_accepts_integer_timeout() {
|
||||
let raw = br#"{"method":"GET","url":"https://example.com","headers":{},"timeout":15}"#;
|
||||
let meta: RequestMeta = serde_json::from_slice(raw).expect("parse request meta");
|
||||
assert_eq!(meta.timeout, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_meta_accepts_integer_like_float_timeout() {
|
||||
let raw = br#"{"method":"GET","url":"https://example.com","headers":{},"timeout":15.0}"#;
|
||||
let meta: RequestMeta = serde_json::from_slice(raw).expect("parse request meta");
|
||||
assert_eq!(meta.timeout, 15);
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
//! Per-stream request handler.
|
||||
//!
|
||||
//! Receives request frames, executes the upstream HTTP request,
|
||||
//! and sends response frames back through the writer channel.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::state::{AppState, ServerContext};
|
||||
use crate::target_filter;
|
||||
|
||||
use super::protocol::{
|
||||
compress_payload, decompress_if_gzip, flags, Frame, MsgType, RequestMeta, ResponseMeta,
|
||||
};
|
||||
use super::writer::FrameSender;
|
||||
|
||||
/// Maximum response body chunk size per frame (32 KB).
|
||||
const MAX_CHUNK_SIZE: usize = 32 * 1024;
|
||||
|
||||
/// Timeout for sending a single frame to the writer channel.
|
||||
/// If the writer is congested (TCP backpressure), we abandon the stream
|
||||
/// rather than blocking indefinitely and exhausting the stream pool.
|
||||
const FRAME_SEND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Minimum allowed upstream request timeout (seconds).
|
||||
const MIN_TIMEOUT_SECS: u64 = 5;
|
||||
/// Maximum allowed upstream request timeout (seconds).
|
||||
const MAX_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Headers that must not be forwarded to upstream (hop-by-hop or security-sensitive).
|
||||
const BLOCKED_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
/// Handle a single stream: receive body, execute upstream, send response.
|
||||
pub async fn handle_stream(
|
||||
state: Arc<AppState>,
|
||||
server: Arc<ServerContext>,
|
||||
stream_id: u32,
|
||||
meta: RequestMeta,
|
||||
mut body_rx: mpsc::Receiver<Frame>,
|
||||
frame_tx: FrameSender,
|
||||
) {
|
||||
server.active_connections.fetch_add(1, Ordering::Release);
|
||||
|
||||
let connect_elapsed =
|
||||
handle_stream_inner(&state, &server, stream_id, meta, &mut body_rx, &frame_tx).await;
|
||||
|
||||
server.active_connections.fetch_sub(1, Ordering::Release);
|
||||
if let Some(d) = connect_elapsed {
|
||||
server.metrics.record_request(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a frame to the writer with a timeout. Returns false if send failed.
|
||||
async fn send_frame(tx: &FrameSender, frame: Frame) -> bool {
|
||||
match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(_)) => {
|
||||
// Channel closed (writer exited)
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout — writer is congested
|
||||
warn!("frame send timeout (writer congested), abandoning stream");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the connection-establishment duration (DNS + TCP/TLS + TTFB) if the
|
||||
/// upstream request succeeded, or `None` if the request never reached the
|
||||
/// response-headers stage.
|
||||
async fn handle_stream_inner(
|
||||
state: &AppState,
|
||||
server: &ServerContext,
|
||||
stream_id: u32,
|
||||
meta: RequestMeta,
|
||||
body_rx: &mut mpsc::Receiver<Frame>,
|
||||
frame_tx: &FrameSender,
|
||||
) -> Option<Duration> {
|
||||
// Collect request body
|
||||
let mut body_parts: Vec<Bytes> = Vec::new();
|
||||
let mut body_done = false;
|
||||
|
||||
// Drain body frames
|
||||
while !body_done {
|
||||
match body_rx.recv().await {
|
||||
Some(frame) => {
|
||||
if frame.msg_type == MsgType::RequestBody {
|
||||
let payload = match decompress_if_gzip(&frame) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
send_error(
|
||||
frame_tx,
|
||||
stream_id,
|
||||
&format!("gzip decompress failed: {e}"),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !payload.is_empty() {
|
||||
body_parts.push(payload);
|
||||
}
|
||||
if frame.is_end_stream() {
|
||||
body_done = true;
|
||||
}
|
||||
} else if frame.msg_type == MsgType::StreamEnd
|
||||
|| frame.msg_type == MsgType::StreamError
|
||||
{
|
||||
body_done = true;
|
||||
if frame.msg_type == MsgType::StreamError {
|
||||
return None; // Client cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
None => return None, // Channel closed
|
||||
}
|
||||
}
|
||||
|
||||
let body: Bytes = if body_parts.is_empty() {
|
||||
Bytes::new()
|
||||
} else if body_parts.len() == 1 {
|
||||
body_parts.into_iter().next().unwrap()
|
||||
} else {
|
||||
let total: usize = body_parts.iter().map(|b| b.len()).sum();
|
||||
let mut combined = Vec::with_capacity(total);
|
||||
for part in &body_parts {
|
||||
combined.extend_from_slice(part);
|
||||
}
|
||||
Bytes::from(combined)
|
||||
};
|
||||
|
||||
// Validate target
|
||||
let target_url = match url::Url::parse(&meta.url) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
send_error(frame_tx, stream_id, &format!("invalid URL: {e}")).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Only allow http/https schemes (block file://, data://, etc.)
|
||||
match target_url.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => {
|
||||
send_error(
|
||||
frame_tx,
|
||||
stream_id,
|
||||
&format!("unsupported URL scheme: {other}"),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let host = match target_url.host_str() {
|
||||
Some(h) => h.to_string(),
|
||||
None => {
|
||||
send_error(frame_tx, stream_id, "missing host in URL").await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let port = target_url.port_or_known_default().unwrap_or(443);
|
||||
|
||||
// DNS + target validation (populates dns_cache for SafeDnsResolver)
|
||||
let connect_start = Instant::now();
|
||||
{
|
||||
let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports);
|
||||
if let Err(e) =
|
||||
target_filter::validate_target(&host, port, &allowed_ports, &state.dns_cache).await
|
||||
{
|
||||
server.metrics.dns_failures.fetch_add(1, Ordering::Release);
|
||||
send_error(frame_tx, stream_id, &format!("target blocked: {e}")).await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let dns_ms = connect_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Execute upstream request
|
||||
let client = &state.reqwest_client;
|
||||
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
|
||||
|
||||
let method: reqwest::Method = meta.method.parse().unwrap_or(reqwest::Method::GET);
|
||||
// Build a complete HeaderMap from tunnel headers, then set it all at once
|
||||
// via .headers() which *replaces* reqwest defaults (e.g. Accept: */*),
|
||||
// ensuring upstream sees exactly what Aether server intended.
|
||||
let mut header_map = reqwest::header::HeaderMap::with_capacity(meta.headers.len());
|
||||
for (k, v) in &meta.headers {
|
||||
let k_lower = k.to_ascii_lowercase();
|
||||
if BLOCKED_HEADERS.contains(&k_lower.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
reqwest::header::HeaderName::from_bytes(k.as_bytes()),
|
||||
reqwest::header::HeaderValue::from_str(v),
|
||||
) {
|
||||
header_map.insert(name, value);
|
||||
}
|
||||
}
|
||||
let mut req = client.request(method, &meta.url).headers(header_map);
|
||||
let body_size = body.len();
|
||||
if !body.is_empty() {
|
||||
req = req.body(body);
|
||||
}
|
||||
req = req.timeout(timeout);
|
||||
|
||||
let upstream_start = Instant::now();
|
||||
let response = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
server
|
||||
.metrics
|
||||
.failed_requests
|
||||
.fetch_add(1, Ordering::Release);
|
||||
let msg = if e.is_timeout() {
|
||||
"upstream timeout".to_string()
|
||||
} else if e.is_connect() {
|
||||
format!("upstream connect error: {e}")
|
||||
} else {
|
||||
format!("upstream error: {e}")
|
||||
};
|
||||
send_error(frame_tx, stream_id, &msg).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Capture connection-establishment duration (DNS + TCP/TLS + TTFB)
|
||||
// before proceeding to stream the response body.
|
||||
let connect_elapsed = connect_start.elapsed();
|
||||
|
||||
// Send RESPONSE_HEADERS
|
||||
let status = response.status().as_u16();
|
||||
let ttfb_ms = upstream_start.elapsed().as_millis() as u64;
|
||||
let mut resp_headers: Vec<(String, String)> = Vec::with_capacity(response.headers().len() + 1);
|
||||
for (k, v) in response.headers() {
|
||||
if let Ok(vs) = v.to_str() {
|
||||
resp_headers.push((k.as_str().to_string(), vs.to_string()));
|
||||
}
|
||||
}
|
||||
// Inject proxy timing (same format as delegate mode)
|
||||
let timing = serde_json::json!({
|
||||
"dns_ms": dns_ms,
|
||||
"ttfb_ms": ttfb_ms,
|
||||
"upstream_ms": ttfb_ms,
|
||||
"upstream_processing_ms": ttfb_ms.saturating_sub(dns_ms),
|
||||
"body_size": body_size,
|
||||
"mode": "tunnel",
|
||||
});
|
||||
resp_headers.push(("x-proxy-timing".to_string(), timing.to_string()));
|
||||
let resp_meta = ResponseMeta {
|
||||
status,
|
||||
headers: resp_headers,
|
||||
};
|
||||
let meta_json: Bytes = serde_json::to_vec(&resp_meta).unwrap_or_default().into();
|
||||
let (meta_payload, meta_flags) = compress_payload(meta_json);
|
||||
if !send_frame(
|
||||
frame_tx,
|
||||
Frame::new(
|
||||
stream_id,
|
||||
MsgType::ResponseHeaders,
|
||||
meta_flags,
|
||||
meta_payload,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Some(connect_elapsed);
|
||||
}
|
||||
|
||||
// Stream response body — relay upstream bytes through the tunnel.
|
||||
// Apply tunnel-level frame compression for chunks that benefit from it
|
||||
// (e.g. uncompressed SSE text). Already-compressed data (gzip/br from
|
||||
// upstream Content-Encoding) won't shrink further and will be sent as-is
|
||||
// thanks to the size check in compress_payload().
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if chunk.len() <= MAX_CHUNK_SIZE {
|
||||
let (payload, extra_flags) = compress_payload(chunk);
|
||||
if !send_frame(
|
||||
frame_tx,
|
||||
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Some(connect_elapsed);
|
||||
}
|
||||
} else {
|
||||
// Split oversized chunks, compress each slice
|
||||
let mut offset = 0;
|
||||
while offset < chunk.len() {
|
||||
let end = (offset + MAX_CHUNK_SIZE).min(chunk.len());
|
||||
let slice = chunk.slice(offset..end);
|
||||
let (payload, extra_flags) = compress_payload(slice);
|
||||
if !send_frame(
|
||||
frame_tx,
|
||||
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Some(connect_elapsed);
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
server.metrics.stream_errors.fetch_add(1, Ordering::Release);
|
||||
warn!(stream_id, error = %e, "upstream body read error");
|
||||
send_error(frame_tx, stream_id, &format!("body read error: {e}")).await;
|
||||
return Some(connect_elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send STREAM_END
|
||||
let _ = send_frame(
|
||||
frame_tx,
|
||||
Frame::new(
|
||||
stream_id,
|
||||
MsgType::StreamEnd,
|
||||
flags::END_STREAM,
|
||||
Bytes::new(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
debug!(stream_id, status, "stream completed");
|
||||
Some(connect_elapsed)
|
||||
}
|
||||
|
||||
async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
|
||||
// Error frames use best-effort delivery — don't block if writer is congested
|
||||
let _ = send_frame(
|
||||
tx,
|
||||
Frame::new(
|
||||
stream_id,
|
||||
MsgType::StreamError,
|
||||
0,
|
||||
Bytes::from(msg.to_string()),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Dedicated WebSocket writer task.
|
||||
//!
|
||||
//! All frame writes go through an mpsc channel to a single writer task,
|
||||
//! avoiding contention on the WebSocket sink. The writer also sends
|
||||
//! periodic WebSocket Ping frames to keep the connection alive through
|
||||
//! intermediary proxies (Nginx, Cloudflare, etc.).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::SinkExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
use super::protocol::Frame;
|
||||
|
||||
/// Sender half — cloned by stream handlers and heartbeat.
|
||||
pub type FrameSender = mpsc::Sender<Frame>;
|
||||
|
||||
/// Spawn the writer task. Returns the sender and a JoinHandle for cleanup.
|
||||
///
|
||||
/// `ping_interval` controls WebSocket-level Ping frequency (typically 15s).
|
||||
/// This keeps the connection alive through intermediary proxies/load-balancers.
|
||||
pub fn spawn_writer<S>(mut sink: S, ping_interval: Duration) -> (FrameSender, JoinHandle<()>)
|
||||
where
|
||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
|
||||
{
|
||||
let (tx, mut rx) = mpsc::channel::<Frame>(256);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut ping_ticker = tokio::time::interval(ping_interval);
|
||||
ping_ticker.tick().await; // skip first immediate tick
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = rx.recv() => {
|
||||
match frame {
|
||||
Some(frame) => {
|
||||
let data = frame.encode();
|
||||
if let Err(e) = sink.send(Message::Binary(data.into())).await {
|
||||
error!(error = %e, "failed to write frame to WebSocket");
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break, // all senders dropped
|
||||
}
|
||||
}
|
||||
_ = ping_ticker.tick() => {
|
||||
if let Err(e) = sink.send(Message::Ping(vec![])).await {
|
||||
error!(error = %e, "failed to send WebSocket ping");
|
||||
break;
|
||||
}
|
||||
trace!("sent WebSocket ping");
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("writer task exiting");
|
||||
let _ = sink.close().await;
|
||||
});
|
||||
|
||||
(tx, handle)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
test
|
||||
fixtures
|
||||
vscode-extension
|
||||
*.vsix
|
||||
coverage
|
||||
data
|
||||
.DS_Store
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
vscode-extension/node_modules/
|
||||
vscode-extension/dist/
|
||||
data/
|
||||
coverage/
|
||||
*.vsix
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8788 \
|
||||
AETHER_VSCODEX_DATA_DIR=/var/lib/aether-vscodex
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY cloud ./cloud
|
||||
COPY relay ./relay
|
||||
COPY public ./public
|
||||
|
||||
RUN mkdir -p /var/lib/aether-vscodex && chown -R node:node /var/lib/aether-vscodex /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 8788
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:8788/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "cloud/server.js"]
|
||||
@@ -0,0 +1,283 @@
|
||||
# aether-vscodex
|
||||
|
||||
这个项目让浏览器从本机 URL 或 Aether 云端查看、输入并处理 Codex 会话,提供两种
|
||||
可随时切换的控制模式。默认的**同步模式**通过官方扩展使用的本机 IPC socket,严格
|
||||
跟随 VS Code Codex 面板当前会话,不启动另一个 `codex` 进程;**异步模式**由伴随扩展
|
||||
启动独立 app-server,网页可以自行列出、恢复、新建和切换会话。
|
||||
|
||||
同一个伴随扩展可同时连接两个互不替代的通道:本机 loopback 控制台和部署在
|
||||
Aether 中的云端控制台。本机通道默认免密码且只能从本机访问;云端通道使用
|
||||
Aether 登录鉴权、一次性浏览器票据和独立设备凭据;父页面不会通过协议把 Aether JWT
|
||||
传给 iframe 或 Node sidecar。iframe 是随 Aether 一起发布的同源受信代码,不应被视为
|
||||
隔离不受信内容的安全边界。
|
||||
|
||||
`vscode-extension/codex-remote-collab-0.4.0.vsix` 安装到 VS Code 后,会作为官方
|
||||
`openai.chatgpt` Codex 扩展的伴随扩展,并自动托管只监听本机的 relay。开发时仍可
|
||||
单独运行 `relay/server.js`。不要卸载或替换官方 Codex 扩展。
|
||||
|
||||
## 工作方式
|
||||
|
||||
```text
|
||||
官方 VS Code Codex 会话
|
||||
│ 本机私有 IPC(只在 VS Code 所在机器上)
|
||||
▼
|
||||
┌── 本机 relay ── http://127.0.0.1:8787
|
||||
VS Code aether-vscodex 扩展 ────┤
|
||||
└── Aether gateway ── 用户/设备隔离的云端 relay
|
||||
```
|
||||
|
||||
控制模式与传输通道是两个独立维度:切换同步/异步不会重连本地或云端 relay。本机和
|
||||
Aether 控制页连接到同一台 VS Code 主机时,会看到同一个当前模式。
|
||||
|
||||
| 控制模式 | 会话所有者 | 网页会话导航 |
|
||||
| --- | --- | --- |
|
||||
| 同步 | 官方 VS Code Codex 面板 | 禁止网页自行切换;自动跟随 VS Code |
|
||||
| 异步 | 扩展启动的独立 app-server | 可列出、恢复、新建和切换会话 |
|
||||
|
||||
浏览器的 `operator` 可以发送任务、继续/中断当前 turn,并处理 Codex 的审批、
|
||||
用户输入和 MCP elicitation;`viewer` 只能查看事件和输出。远程浏览器不接触
|
||||
VS Code 的 SecretStorage,也不直接连接 IPC socket。
|
||||
|
||||
## 前端结构
|
||||
|
||||
Aether 页面使用仓库既有的 Vue 3、TypeScript、Vite 和 i18n。独立控制台也提供
|
||||
Vue/Vite 源码入口,但当前高保真的会话渲染与协议状态机作为兼容运行时保留,构建到
|
||||
`public/` 后同时供本机 URL 和 Aether 同源 iframe 使用。这样不需要一次性重写并丢失
|
||||
命令展开、滚动锚点、思考状态、Markdown、子代理、模型和权限菜单等已有行为。
|
||||
|
||||
界面支持 `zh-CN` 与 `en-US`。Aether 的语言和深浅色主题会通过经过来源校验的
|
||||
`postMessage` 同步给 iframe;VS Code 命令与设置说明使用 `package.nls` 本地化。
|
||||
|
||||
## Aether 云端部署
|
||||
|
||||
云端模式由 Aether gateway 和独立 Node sidecar 组成。sidecar 只在 Compose 内网暴露
|
||||
8788,公网的 HTTP、配对交换和 WebSocket 都经 Aether gateway:
|
||||
|
||||
```text
|
||||
GET /api/users/me/vscodex/devices
|
||||
POST /api/users/me/vscodex/pairings
|
||||
DELETE /api/users/me/vscodex/devices/:device_id
|
||||
POST /api/users/me/vscodex/ws-tickets
|
||||
POST /api/vscodex/pair
|
||||
WS /api/vscodex/ws
|
||||
```
|
||||
|
||||
生成至少 32 字节的内部令牌,并按 Aether 的公开 HTTPS 地址设置变量:
|
||||
|
||||
```sh
|
||||
export AETHER_VSCODEX_INTERNAL_TOKEN="$(openssl rand -base64 32)"
|
||||
export AETHER_VSCODEX_PUBLIC_WS_URL="wss://aether.example.com/api/vscodex/ws"
|
||||
export AETHER_VSCODEX_ALLOWED_ORIGINS="https://aether.example.com"
|
||||
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.local.yml \
|
||||
-f aether-vscodex/docker-compose.aether.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
源码部署必须包含 `docker-compose.local.yml`,以保证 gateway、前端和 sidecar 来自同一份
|
||||
checkout。使用发布镜像时可以去掉该文件,但 `APP_IMAGE` 必须固定为包含相同
|
||||
`aether-vscodex` 协议版本的 Aether 镜像,不能把当前 sidecar 与旧的 `latest` gateway 混用。
|
||||
|
||||
首次使用源码 Compose 前先构建控制台;正式 Aether 发布流程与 Dockerfile 已自动执行
|
||||
同一步骤:
|
||||
|
||||
```sh
|
||||
npm --prefix aether-vscodex/web ci
|
||||
npm --prefix aether-vscodex/web run build
|
||||
```
|
||||
|
||||
第一阶段 sidecar 是有状态单副本:设备凭据的 scrypt 哈希保存在
|
||||
`vscodex_data`,短期配对码、60 秒一次性浏览器票据和在线房间保存在内存。不要在未引入
|
||||
共享连接目录前横向扩容 sidecar。
|
||||
|
||||
登录 Aether 后打开“远程控制”,生成一次性配对码。然后在 VS Code 命令面板执行
|
||||
**Codex Remote: Pair with Aether**,填写 Aether 地址和配对码。插件会把设备凭据写入
|
||||
VS Code SecretStorage,并同时保持本机控制台连接。
|
||||
|
||||
## 快速开始
|
||||
|
||||
前提:Node.js 20+;官方 `openai.chatgpt` VS Code 扩展已安装并登录;目标会话
|
||||
已经在 VS Code 的 Codex 面板中打开。VS Code 和 relay 必须以同一个操作系统用户
|
||||
运行,因为 IPC socket 是本机文件。
|
||||
|
||||
1. 安装依赖并构建伴随扩展:
|
||||
|
||||
```sh
|
||||
npm --prefix vscode-extension install
|
||||
npm --prefix vscode-extension run build
|
||||
```
|
||||
|
||||
本机 `ws://` 地址会由扩展自动启动 relay;loopback 模式默认不需要 token,且
|
||||
`host` 模式不会启动 `codex app-server`。
|
||||
|
||||
2. 安装 `vscode-extension/codex-remote-collab-0.4.0.vsix`(或在扩展目录先
|
||||
`npm run build` 再用 `npx --yes @vscode/vsce package` 打包),然后在 VS Code
|
||||
执行 **Developer: Reload Window**。
|
||||
|
||||
3. 在 VS Code 设置中填写:
|
||||
|
||||
```json
|
||||
{
|
||||
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
|
||||
"codexRemoteCollab.controlMode": "sync",
|
||||
"codexRemoteCollab.autoDiscoverThread": true,
|
||||
"codexRemoteCollab.autoStart": true
|
||||
}
|
||||
```
|
||||
|
||||
4. 执行一次 **Developer: Reload Window** 后,扩展会自动找到最近的、仍由官方
|
||||
VS Code Codex owner 持有的会话,并把已有输出同步到 relay;如果没有自动启动,
|
||||
无需手动启动或断开。右下角状态项只用于显示状态并打开 Web。需要精确指定会话时,执行
|
||||
**Codex Remote: Set Existing Thread ID**;留空则恢复自动发现。
|
||||
官方 Codex 面板切换会话时,Web 默认会在新会话快照就绪后自动跟随;正在执行或等待
|
||||
授权的旧会话会先保持附着,结束后再安全切换。
|
||||
|
||||
5. 浏览器打开 `http://127.0.0.1:8787`,页面会自动以本机 operator 身份连接,
|
||||
不需要输入密码。
|
||||
|
||||
如果页面显示“等待 VS Code 主机连接”,先确认 relay 地址与扩展设置的端口完全一致,
|
||||
然后在 VS Code 执行一次 **Developer: Reload Window**。同步模式必须在官方 Codex
|
||||
面板已经打开至少一个会话后才能发现 owner;通常不需要手工填写
|
||||
`codexRemoteCollab.threadId`,留空会自动选择最近的可用会话。若之前填写过已经关闭的
|
||||
thread ID,清空该设置后再重载窗口。
|
||||
|
||||
### 发布与下载插件
|
||||
|
||||
正式发布时不需要用户在本地编译。仓库的 `.github/workflows/release.yml` 在推送
|
||||
`vX.Y.Z`、`vX.Y.Z-beta.N` 或 `vX.Y.Z-rc.N` 标签时,会在 GitHub Actions 中完成 Web
|
||||
前端构建、扩展编译和 VSIX 打包,并把
|
||||
`aether-vscodex-<extension-version>.vsix` 附加到对应的 GitHub Release。用户从 Release
|
||||
页面下载该 VSIX,在 VS Code 的扩展视图中选择“从 VSIX 安装...”即可;安装后执行一次
|
||||
**Developer: Reload Window**。
|
||||
|
||||
手动运行该 workflow 时,VSIX 会作为 `aether-vscodex-vsix` Actions artifact 提供下载,
|
||||
但不会创建 GitHub Release。源码目录中的 VSIX 只用于本地开发验证,不是用户发布渠道。
|
||||
|
||||
如果命令面板提示 `command 'codexRemoteCollab.start' not found`,通常是旧版
|
||||
VSIX 激活失败(旧包可能没有包含 `ws` 运行依赖)。请安装当前的
|
||||
`codex-remote-collab-0.4.0.vsix` 并使用 `--force` 覆盖旧版本,然后执行一次
|
||||
**Developer: Reload Window**:
|
||||
|
||||
```sh
|
||||
code --install-extension vscode-extension/codex-remote-collab-0.4.0.vsix --force
|
||||
```
|
||||
|
||||
也可以在 **Output → Codex Remote Collaboration** 中确认没有
|
||||
`Cannot find module 'ws'`;出现该错误时,说明扩展尚未成功激活。
|
||||
|
||||
网页现在按官方 Codex Webview 的会话模型展示:历史和实时输出在中间消息流,用户、
|
||||
助手、reasoning、命令输出分别投影为对应的消息项;助手内容支持安全的 Markdown、
|
||||
代码块和复制操作,reasoning/命令活动可折叠。底部 composer 使用可编辑富文本区域,
|
||||
回车发送、Shift+Enter 换行;审批和用户输入会以内嵌 card 出现在会话流中,支持风险
|
||||
标记、输入控件、授权范围和明确的允许/拒绝动作。附着适配器会额外发送可选的
|
||||
`messages` 角色投影,旧版 host 没有该字段时网页仍回退到纯文本快照。
|
||||
|
||||
页面打开后自动连接并在断线后重连,不再需要手动点击“连接”或“断开”。同步模式下
|
||||
会话列表、返回历史和新建入口会被禁用,所有输入都发送到 VS Code 当前会话。这里复刻的是从本机已安装
|
||||
官方 bundle 审计出的布局、状态和交互;官方 bundle 依赖 VS Code 私有 Webview API,
|
||||
不能安全地直接作为 iframe 嵌入浏览器。
|
||||
|
||||
底部的“同步 / 异步”分段控件发送 `control/mode/set`。当前 turn 正在执行或存在待处理
|
||||
授权、用户输入时,主机拒绝切换;候选适配器启动失败时保留原模式和原会话。切入异步
|
||||
模式后,页面顶部会恢复会话历史、新建和选择入口;`session/list` 映射到
|
||||
`thread/list`,选择会话使用 `thread/resume` 并水合完整历史,新建会话使用
|
||||
`thread/start`。切回同步模式会关闭独立 app-server,并重新以 VS Code 面板为唯一
|
||||
会话导航来源。
|
||||
|
||||
### 认证(可选)
|
||||
|
||||
如果以后需要保护 relay,可显式开启认证;本机流程默认不需要这些变量:
|
||||
|
||||
```sh
|
||||
CODEX_REMOTE_AUTH=required \
|
||||
CODEX_REMOTE_HOST_TOKEN='host-only-secret' \
|
||||
CODEX_REMOTE_TOKEN='browser-operator-secret' \
|
||||
CODEX_REMOTE_VIEW_TOKEN='browser-viewer-secret' \
|
||||
CODEX_REMOTE_MODE=host npm start
|
||||
```
|
||||
|
||||
认证开启后,Host token 填在 VS Code 扩展中,Operator/Viewer token 填在浏览器中。
|
||||
|
||||
## `spawn codex ENOENT` 是什么
|
||||
|
||||
这个错误只表示某处正在尝试启动**独立**的 `codex app-server`,但 VS Code 图形
|
||||
进程的 `PATH` 找不到可执行文件。对于本项目默认的同步模式,不会调用
|
||||
`spawn codex`,因此不需要通过设置 `codexCommand` 来修复它。
|
||||
|
||||
只有切换到异步模式(或仍使用旧版兼容设置)才需要独立可执行文件:
|
||||
|
||||
```json
|
||||
"codexRemoteCollab.controlMode": "async"
|
||||
```
|
||||
|
||||
扩展会优先解析 `codexRemoteCollab.codexCommand`,并可回退到官方 Codex 扩展内置的
|
||||
可执行文件;`codexRemoteCollab.codexArgs` 默认是 `["app-server", "--stdio"]`。
|
||||
旧 `mode=attach/spawn` 会分别迁移为 `sync/async`。
|
||||
|
||||
## Relay 模式
|
||||
|
||||
### `host`(推荐)
|
||||
|
||||
relay 只负责认证、事件缓存和转发;VS Code 扩展通过私有 IPC 附着官方 Codex
|
||||
会话。必须先打开目标会话;本机 loopback 默认不需要 host token,只有显式开启认证时
|
||||
才把 host token 提供给扩展。
|
||||
|
||||
### `embedded`(旧的独立进程模式)
|
||||
|
||||
只有显式设置 `CODEX_REMOTE_MODE=embedded` 时,relay 才会启动自己的
|
||||
`codex app-server --stdio`,适合测试页面和公开 app-server 协议;它与 VS Code
|
||||
当前会话无关:
|
||||
|
||||
```sh
|
||||
CODEX_REMOTE_MODE=embedded CODEX_CWD="$PWD" npm start
|
||||
```
|
||||
|
||||
`CODEX_BIN` 可指定独立进程的可执行文件;`CODEX_ARGS_JSON` 可覆盖其参数。不要
|
||||
把这些设置误认为 attach 模式的必要配置。
|
||||
|
||||
## HTTP API
|
||||
|
||||
认证开启时,除 `/api/health` 外的 `/api/*` 都需要
|
||||
`Authorization: Bearer <operator-or-viewer-token>` 或 `X-Codex-Token`;本机免认证
|
||||
模式下 loopback 请求直接作为 operator 处理。
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/state
|
||||
GET /api/events?fromSeq=0
|
||||
POST /api/command {"commandId":"...","method":"turn/start","params":{...}}
|
||||
POST /api/respond {"requestId":"...","result":{...}}
|
||||
```
|
||||
|
||||
host 模式下,同步控制会拒绝 `thread/start` 和网页会话导航;异步控制会把它们转给
|
||||
独立 app-server。浏览器使用 `threadId` 发送 `turn/start`、`turn/steer` 或
|
||||
`turn/interrupt`。认证开启时写操作和
|
||||
响应请求必须使用 operator token;本机免认证模式下 loopback operator 可直接操作。
|
||||
|
||||
## 私有协议和限制
|
||||
|
||||
- IPC follower 协议是官方 VS Code 扩展的私有、带版本号实现,不是公开 API;官方
|
||||
扩展升级后可能需要同步适配。启用 `codexRemoteCollab.ipcStrictVersions`
|
||||
时,未知 stream 版本会让连接报错而不是猜测执行。
|
||||
- 自动发现只把本地 rollout 元数据当作候选,最终仍通过 IPC owner discovery
|
||||
验证;生产或多会话场景建议设置明确的 `threadId`。
|
||||
- relay 默认只监听 loopback,且 loopback 默认免认证;这意味着同一台机器上能访问
|
||||
loopback 的本地进程都可能控制会话,不要把它反向代理或暴露到外部。如果开启 token
|
||||
认证,token 是 bearer secret。高风险授权默认被 host policy 拒绝,只有显式设置
|
||||
`codexRemoteCollab.allowHighRiskApprovals=true` 才允许。
|
||||
- 输出会做常见 token/密码脱敏,但不能识别所有秘密;不要把凭据发送给 Codex。
|
||||
- 当前 UI 控制一个 host 会话,不提供多人同时编辑或文件同步。
|
||||
|
||||
## 测试
|
||||
|
||||
根目录测试使用假的 stdio app-server,不会向真实 Codex 发送任务:
|
||||
|
||||
```sh
|
||||
npm test
|
||||
cd vscode-extension && npm run check && npm run build
|
||||
```
|
||||
|
||||
要验证真实附着,只读地打开官方 VS Code 会话后启动 bridge;不要在验证脚本中
|
||||
调用 `turn/start`,除非你确实要向该会话发送任务。
|
||||
@@ -0,0 +1,727 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
const { WebSocket, WebSocketServer } = require("ws");
|
||||
|
||||
const { CodexRelay } = require("../relay/server.js");
|
||||
|
||||
const MAX_JSON_BYTES = 64 * 1024;
|
||||
const MAX_WS_BYTES = 16 * 1024 * 1024;
|
||||
const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_TICKET_TTL_MS = 60 * 1000;
|
||||
const DEFAULT_ROOM_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
class DeviceStore {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.data = { version: 1, devices: [] };
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.devices)) throw new Error("unsupported device store format");
|
||||
this.data = parsed;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
list(userId, connectedDeviceIds = new Set()) {
|
||||
return this.data.devices
|
||||
.filter((device) => device.user_id === userId && !device.revoked_at)
|
||||
.map((device) => publicDevice(device, connectedDeviceIds.has(device.id)));
|
||||
}
|
||||
|
||||
create(userId, name) {
|
||||
const id = crypto.randomUUID();
|
||||
const secret = crypto.randomBytes(32).toString("base64url");
|
||||
const salt = crypto.randomBytes(16).toString("base64url");
|
||||
const now = new Date().toISOString();
|
||||
const device = {
|
||||
id,
|
||||
user_id: userId,
|
||||
name: normalizeName(name),
|
||||
secret_salt: salt,
|
||||
secret_hash: deriveSecret(secret, salt),
|
||||
created_at: now,
|
||||
last_seen_at: null,
|
||||
revoked_at: null,
|
||||
};
|
||||
this.data.devices.push(device);
|
||||
this.persist();
|
||||
return { device: publicDevice(device, false), token: `avx1.${id}.${secret}` };
|
||||
}
|
||||
|
||||
authenticate(token) {
|
||||
const parsed = parseDeviceToken(token);
|
||||
if (!parsed) return null;
|
||||
const device = this.data.devices.find((candidate) => candidate.id === parsed.id && !candidate.revoked_at);
|
||||
if (!device) return null;
|
||||
const actual = Buffer.from(deriveSecret(parsed.secret, device.secret_salt), "base64url");
|
||||
const expected = Buffer.from(device.secret_hash, "base64url");
|
||||
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) return null;
|
||||
return device;
|
||||
}
|
||||
|
||||
get(userId, deviceId) {
|
||||
return this.data.devices.find((device) => device.user_id === userId && device.id === deviceId && !device.revoked_at) || null;
|
||||
}
|
||||
|
||||
touch(deviceId) {
|
||||
const device = this.data.devices.find((candidate) => candidate.id === deviceId && !candidate.revoked_at);
|
||||
if (!device) return;
|
||||
device.last_seen_at = new Date().toISOString();
|
||||
this.persist();
|
||||
}
|
||||
|
||||
revoke(userId, deviceId) {
|
||||
const device = this.get(userId, deviceId);
|
||||
if (!device) return false;
|
||||
device.revoked_at = new Date().toISOString();
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
persist() {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporary, this.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
class EphemeralCredentials {
|
||||
constructor(options = {}) {
|
||||
this.pairingTtlMs = options.pairingTtlMs || DEFAULT_PAIRING_TTL_MS;
|
||||
this.ticketTtlMs = options.ticketTtlMs || DEFAULT_TICKET_TTL_MS;
|
||||
this.pairings = new Map();
|
||||
this.tickets = new Map();
|
||||
}
|
||||
|
||||
createPairing(userId, requestedName) {
|
||||
const code = pairingCode();
|
||||
const record = {
|
||||
id: crypto.randomUUID(),
|
||||
code,
|
||||
user_id: userId,
|
||||
requested_name: normalizeName(requestedName),
|
||||
expires_at_ms: Date.now() + this.pairingTtlMs,
|
||||
};
|
||||
this.pairings.set(normalizePairingCode(code), record);
|
||||
return record;
|
||||
}
|
||||
|
||||
consumePairing(code) {
|
||||
const key = normalizePairingCode(code);
|
||||
const record = this.pairings.get(key);
|
||||
this.pairings.delete(key);
|
||||
if (!record || record.expires_at_ms <= Date.now()) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
createTicket(userId, deviceId) {
|
||||
const ticket = `avt1.${crypto.randomBytes(32).toString("base64url")}`;
|
||||
this.tickets.set(ticket, {
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
expires_at_ms: Date.now() + this.ticketTtlMs,
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
consumeTicket(ticket) {
|
||||
const record = this.tickets.get(ticket);
|
||||
this.tickets.delete(ticket);
|
||||
if (!record || record.expires_at_ms <= Date.now()) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of this.pairings) if (record.expires_at_ms <= now) this.pairings.delete(key);
|
||||
for (const [key, record] of this.tickets) if (record.expires_at_ms <= now) this.tickets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
class RoomManager {
|
||||
constructor(options = {}) {
|
||||
this.rooms = new Map();
|
||||
this.pendingRooms = new Map();
|
||||
this.revokedRoomKeys = new Set();
|
||||
this.idleMs = options.idleMs || DEFAULT_ROOM_IDLE_MS;
|
||||
}
|
||||
|
||||
key(userId, deviceId) {
|
||||
return `${encodeURIComponent(userId)}:${deviceId}`;
|
||||
}
|
||||
|
||||
async get(userId, deviceId) {
|
||||
const key = this.key(userId, deviceId);
|
||||
if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
|
||||
let room = this.rooms.get(key);
|
||||
if (!room && this.pendingRooms.has(key)) room = await this.pendingRooms.get(key);
|
||||
if (!room) {
|
||||
const creating = this.createRoom(key, userId, deviceId);
|
||||
this.pendingRooms.set(key, creating);
|
||||
try {
|
||||
room = await creating;
|
||||
} finally {
|
||||
this.pendingRooms.delete(key);
|
||||
}
|
||||
}
|
||||
if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
|
||||
room.lastActiveMs = Date.now();
|
||||
return room;
|
||||
}
|
||||
|
||||
async createRoom(key, userId, deviceId) {
|
||||
const hostToken = randomToken();
|
||||
const operatorToken = randomToken();
|
||||
const relay = new CodexRelay({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
mode: "host",
|
||||
authRequired: true,
|
||||
hostToken,
|
||||
operatorToken,
|
||||
viewerToken: randomToken(),
|
||||
});
|
||||
await relay.start();
|
||||
if (this.revokedRoomKeys.has(key)) {
|
||||
await relay.stop().catch(() => undefined);
|
||||
throw httpError(401, "device revoked");
|
||||
}
|
||||
const address = relay.address();
|
||||
const room = {
|
||||
key,
|
||||
userId,
|
||||
deviceId,
|
||||
relay,
|
||||
hostToken,
|
||||
operatorToken,
|
||||
baseUrl: `ws://127.0.0.1:${address.port}`,
|
||||
connections: 0,
|
||||
lastActiveMs: Date.now(),
|
||||
};
|
||||
this.rooms.set(key, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
connectedDeviceIds(userId) {
|
||||
return new Set([...this.rooms.values()]
|
||||
.filter((room) => room.userId === userId && room.relay.state.hostConnected)
|
||||
.map((room) => room.deviceId));
|
||||
}
|
||||
|
||||
retain(room) {
|
||||
room.connections += 1;
|
||||
room.lastActiveMs = Date.now();
|
||||
}
|
||||
|
||||
release(room) {
|
||||
room.connections = Math.max(0, room.connections - 1);
|
||||
room.lastActiveMs = Date.now();
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, room] of this.rooms) {
|
||||
if (room.connections > 0 || now - room.lastActiveMs < this.idleMs) continue;
|
||||
this.rooms.delete(key);
|
||||
await room.relay.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(userId, deviceId) {
|
||||
const key = this.key(userId, deviceId);
|
||||
this.revokedRoomKeys.add(key);
|
||||
const pending = this.pendingRooms.get(key);
|
||||
if (pending) await pending.catch(() => undefined);
|
||||
const room = this.rooms.get(key);
|
||||
if (!room) return;
|
||||
this.rooms.delete(key);
|
||||
await room.relay.stop();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await Promise.allSettled([...this.pendingRooms.values()]);
|
||||
this.pendingRooms.clear();
|
||||
const rooms = [...this.rooms.values()];
|
||||
this.rooms.clear();
|
||||
this.revokedRoomKeys.clear();
|
||||
await Promise.allSettled(rooms.map((room) => room.relay.stop()));
|
||||
}
|
||||
}
|
||||
|
||||
class AetherVscodexCloudServer {
|
||||
constructor(options = {}) {
|
||||
this.host = options.host || process.env.HOST || "127.0.0.1";
|
||||
this.port = parsePort(options.port ?? process.env.PORT, 8788);
|
||||
this.internalToken = options.internalToken || process.env.AETHER_VSCODEX_INTERNAL_TOKEN || "";
|
||||
this.publicWsUrl = options.publicWsUrl || process.env.AETHER_VSCODEX_PUBLIC_WS_URL || "";
|
||||
this.allowedOrigins = normalizeOrigins(options.allowedOrigins ?? process.env.AETHER_VSCODEX_ALLOWED_ORIGINS);
|
||||
const dataDir = options.dataDir || process.env.AETHER_VSCODEX_DATA_DIR || path.join(process.cwd(), "data");
|
||||
this.store = options.store || new DeviceStore(path.join(dataDir, "devices.json"));
|
||||
this.credentials = options.credentials || new EphemeralCredentials(options);
|
||||
this.rooms = options.rooms || new RoomManager(options);
|
||||
this.exchangeAttempts = new Map();
|
||||
this.httpServer = null;
|
||||
this.wsServer = null;
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (!this.internalToken) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN is required");
|
||||
if (Buffer.byteLength(this.internalToken, "utf8") < 24) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN must contain at least 24 bytes");
|
||||
if (!this.publicWsUrl) throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL is required");
|
||||
validatePublicWsUrl(this.publicWsUrl);
|
||||
if (!isLoopbackHost(this.host) && this.allowedOrigins.size === 0) {
|
||||
throw new Error("AETHER_VSCODEX_ALLOWED_ORIGINS is required when binding outside loopback");
|
||||
}
|
||||
this.httpServer = http.createServer((request, response) => {
|
||||
void this.handleHttp(request, response).catch((error) => {
|
||||
jsonResponse(response, error.statusCode || 500, { error: error.expose ? error.message : "internal server error" });
|
||||
});
|
||||
});
|
||||
this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_BYTES });
|
||||
this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head));
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.credentials.cleanup();
|
||||
this.cleanupExchangeAttempts();
|
||||
void this.rooms.cleanup();
|
||||
}, 30_000);
|
||||
this.cleanupTimer.unref();
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = (error) => reject(error);
|
||||
this.httpServer.once("error", onError);
|
||||
this.httpServer.listen(this.port, this.host, () => {
|
||||
this.httpServer.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return this.address();
|
||||
}
|
||||
|
||||
address() {
|
||||
const address = this.httpServer.address();
|
||||
if (!address || typeof address === "string") return { host: this.host, port: this.port };
|
||||
return { host: address.address, port: address.port };
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
if (this.wsServer) {
|
||||
for (const client of this.wsServer.clients) client.close(1001, "server shutting down");
|
||||
await new Promise((resolve) => this.wsServer.close(() => resolve()));
|
||||
}
|
||||
if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve()));
|
||||
this.wsServer = null;
|
||||
this.httpServer = null;
|
||||
await this.rooms.stop();
|
||||
}
|
||||
|
||||
async handleHttp(request, response) {
|
||||
const requestUrl = new URL(request.url || "/", "http://sidecar.local");
|
||||
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
|
||||
jsonResponse(response, 200, { ok: true, service: "aether-vscodex", mode: "single-replica" });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && requestUrl.pathname === "/v1/pairings/exchange") {
|
||||
this.enforceExchangeRate(request);
|
||||
const body = await readJson(request);
|
||||
const pairing = this.credentials.consumePairing(body.code);
|
||||
if (!pairing) throw httpError(400, "invalid or expired pairing code");
|
||||
const created = this.store.create(pairing.user_id, body.name || pairing.requested_name);
|
||||
jsonResponse(response, 201, {
|
||||
device_id: created.device.id,
|
||||
device_name: created.device.name,
|
||||
device_token: created.token,
|
||||
ws_url: this.publicWsUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const match = requestUrl.pathname.match(/^\/internal\/v1\/users\/([^/]+)\/(devices|pairings|ws-tickets)(?:\/([^/]+))?$/);
|
||||
if (!match) {
|
||||
jsonResponse(response, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
this.requireInternalAuth(request);
|
||||
const userId = decodeURIComponent(match[1]);
|
||||
const resource = match[2];
|
||||
const resourceId = match[3] ? decodeURIComponent(match[3]) : null;
|
||||
if (!userId || userId.length > 256) throw httpError(400, "invalid user id");
|
||||
|
||||
if (request.method === "GET" && resource === "devices" && !resourceId) {
|
||||
jsonResponse(response, 200, { devices: this.store.list(userId, this.rooms.connectedDeviceIds(userId)) });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && resource === "pairings" && !resourceId) {
|
||||
const body = await readJson(request);
|
||||
const pairing = this.credentials.createPairing(userId, body.name);
|
||||
jsonResponse(response, 201, {
|
||||
pairing_id: pairing.id,
|
||||
code: pairing.code,
|
||||
expires_at: new Date(pairing.expires_at_ms).toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE" && resource === "devices" && resourceId) {
|
||||
if (!this.store.revoke(userId, resourceId)) throw httpError(404, "device not found");
|
||||
await this.rooms.revoke(userId, resourceId);
|
||||
response.writeHead(204, { "Cache-Control": "no-store" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && resource === "ws-tickets" && !resourceId) {
|
||||
const body = await readJson(request);
|
||||
const deviceId = typeof body.device_id === "string" ? body.device_id : "";
|
||||
if (!deviceId || !this.store.get(userId, deviceId)) throw httpError(404, "device not found");
|
||||
jsonResponse(response, 201, {
|
||||
ticket: this.credentials.createTicket(userId, deviceId),
|
||||
ws_url: "/api/vscodex/ws",
|
||||
expires_in: Math.floor(this.credentials.ticketTtlMs / 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
jsonResponse(response, 405, { error: "method not allowed" }, { Allow: allowedMethod(resource, resourceId) });
|
||||
}
|
||||
|
||||
requireInternalAuth(request) {
|
||||
if (!this.hasInternalAuth(request)) throw httpError(401, "unauthorized");
|
||||
}
|
||||
|
||||
hasInternalAuth(request) {
|
||||
const authorization = String(request.headers.authorization || "");
|
||||
const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
||||
return secureEqual(token, this.internalToken);
|
||||
}
|
||||
|
||||
enforceExchangeRate(request) {
|
||||
const address = this.exchangeRateAddress(request);
|
||||
const now = Date.now();
|
||||
const attempts = (this.exchangeAttempts.get(address) || []).filter((time) => now - time < 60_000);
|
||||
if (attempts.length >= 10) throw httpError(429, "too many pairing attempts");
|
||||
attempts.push(now);
|
||||
this.exchangeAttempts.set(address, attempts);
|
||||
}
|
||||
|
||||
exchangeRateAddress(request) {
|
||||
if (this.hasInternalAuth(request)) {
|
||||
const forwardedAddress = singleHeaderValue(request, "x-aether-client-ip")?.trim();
|
||||
if (forwardedAddress && net.isIP(forwardedAddress)) return forwardedAddress;
|
||||
}
|
||||
return request.socket.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
cleanupExchangeAttempts() {
|
||||
const now = Date.now();
|
||||
for (const [address, attempts] of this.exchangeAttempts) {
|
||||
const active = attempts.filter((time) => now - time < 60_000);
|
||||
if (active.length) this.exchangeAttempts.set(address, active);
|
||||
else this.exchangeAttempts.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
handleUpgrade(request, socket, head) {
|
||||
const requestUrl = new URL(request.url || "/", "http://sidecar.local");
|
||||
if (requestUrl.pathname !== "/api/vscodex/ws" && requestUrl.pathname !== "/v1/connect") {
|
||||
rejectUpgrade(socket, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
const origin = request.headers.origin;
|
||||
if (origin && this.allowedOrigins.size > 0 && !this.allowedOrigins.has(normalizeOrigin(origin))) {
|
||||
rejectUpgrade(socket, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
this.wsServer.handleUpgrade(request, socket, head, (webSocket) => {
|
||||
this.wsServer.emit("connection", webSocket, request);
|
||||
this.handleWebSocket(webSocket, request);
|
||||
});
|
||||
}
|
||||
|
||||
handleWebSocket(socket, request) {
|
||||
let hello = null;
|
||||
let token = "";
|
||||
let upstream = null;
|
||||
let authenticating = false;
|
||||
let room = null;
|
||||
const queued = [];
|
||||
const authTimer = setTimeout(() => socket.close(1008, "authentication required"), 10_000);
|
||||
authTimer.unref();
|
||||
|
||||
const connectUpstream = async () => {
|
||||
if (authenticating || upstream || !hello || !token) return;
|
||||
authenticating = true;
|
||||
let identity;
|
||||
let upstreamToken;
|
||||
if (hello.clientType === "host") {
|
||||
const device = this.store.authenticate(token);
|
||||
if (!device) throw httpError(401, "invalid device credential");
|
||||
identity = { userId: device.user_id, deviceId: device.id };
|
||||
room = await this.rooms.get(identity.userId, identity.deviceId);
|
||||
upstreamToken = room.hostToken;
|
||||
this.store.touch(device.id);
|
||||
} else {
|
||||
const ticket = this.credentials.consumeTicket(token);
|
||||
if (!ticket || !this.store.get(ticket.user_id, ticket.device_id)) throw httpError(401, "invalid or expired browser ticket");
|
||||
identity = { userId: ticket.user_id, deviceId: ticket.device_id };
|
||||
room = await this.rooms.get(identity.userId, identity.deviceId);
|
||||
upstreamToken = room.operatorToken;
|
||||
}
|
||||
this.rooms.retain(room);
|
||||
upstream = new WebSocket(`${room.baseUrl}${hello.clientType === "host" ? "/v1/connect" : "/ws"}`, {
|
||||
maxPayload: MAX_WS_BYTES,
|
||||
});
|
||||
upstream.once("open", () => {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
upstream.close();
|
||||
return;
|
||||
}
|
||||
upstream.send(JSON.stringify(hello));
|
||||
upstream.send(JSON.stringify(hello.clientType === "host"
|
||||
? { v: 1, kind: "auth", accessToken: upstreamToken }
|
||||
: { type: "auth", token: upstreamToken }));
|
||||
for (const frame of queued.splice(0)) upstream.send(frame);
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("close", (code, reason) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close(validCloseCode(code) ? code : 1011, reason.toString().slice(0, 120) || "relay closed");
|
||||
});
|
||||
upstream.on("error", () => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close(1011, "relay unavailable");
|
||||
});
|
||||
clearTimeout(authTimer);
|
||||
};
|
||||
|
||||
socket.on("message", (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
socket.close(1003, "JSON text frames only");
|
||||
return;
|
||||
}
|
||||
if (upstream) {
|
||||
const text = data.toString("utf8");
|
||||
if (upstream.readyState === WebSocket.OPEN) upstream.send(text);
|
||||
else queued.push(text);
|
||||
return;
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data.toString("utf8"));
|
||||
} catch {
|
||||
socket.close(1007, "invalid JSON");
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "hello") {
|
||||
if (Number(message.protocol || 1) !== 1) {
|
||||
socket.close(1002, "unsupported protocol");
|
||||
return;
|
||||
}
|
||||
hello = {
|
||||
v: 1,
|
||||
kind: "hello",
|
||||
clientType: message.clientType === "host" ? "host" : "web",
|
||||
protocol: 1,
|
||||
...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}),
|
||||
...(Number.isFinite(Number(message.lastSeq)) ? { lastSeq: Number(message.lastSeq) } : {}),
|
||||
};
|
||||
} else if (message?.kind === "auth" || message?.type === "auth") {
|
||||
token = typeof message.accessToken === "string" ? message.accessToken : typeof message.token === "string" ? message.token : "";
|
||||
} else {
|
||||
socket.close(1002, "hello and auth required");
|
||||
return;
|
||||
}
|
||||
void connectUpstream().catch(() => socket.close(1008, "authentication failed"));
|
||||
});
|
||||
socket.on("close", () => {
|
||||
clearTimeout(authTimer);
|
||||
if (upstream && upstream.readyState < WebSocket.CLOSING) upstream.close();
|
||||
if (room) this.rooms.release(room);
|
||||
});
|
||||
socket.on("error", () => {});
|
||||
}
|
||||
}
|
||||
|
||||
function parseDeviceToken(token) {
|
||||
const match = /^avx1\.([0-9a-f-]{36})\.([A-Za-z0-9_-]{32,})$/.exec(String(token || ""));
|
||||
return match ? { id: match[1], secret: match[2] } : null;
|
||||
}
|
||||
|
||||
function deriveSecret(secret, salt) {
|
||||
return crypto.scryptSync(secret, Buffer.from(salt, "base64url"), 32).toString("base64url");
|
||||
}
|
||||
|
||||
function publicDevice(device, connected) {
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
connected,
|
||||
created_at: device.created_at,
|
||||
last_seen_at: device.last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(value) {
|
||||
const name = typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, 80) : "";
|
||||
return name || "VS Code";
|
||||
}
|
||||
|
||||
function pairingCode() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const bytes = crypto.randomBytes(8);
|
||||
let result = "";
|
||||
for (let index = 0; index < 8; index += 1) result += alphabet[bytes[index] % alphabet.length];
|
||||
return `${result.slice(0, 4)}-${result.slice(4)}`;
|
||||
}
|
||||
|
||||
function normalizePairingCode(value) {
|
||||
return String(value || "").toUpperCase().replace(/[^A-Z2-9]/g, "");
|
||||
}
|
||||
|
||||
function randomToken() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function secureEqual(left, right) {
|
||||
const a = Buffer.from(String(left || ""));
|
||||
const b = Buffer.from(String(right || ""));
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function singleHeaderValue(request, name) {
|
||||
const distinctValues = request.headersDistinct?.[name];
|
||||
if (Array.isArray(distinctValues)) return distinctValues.length === 1 ? distinctValues[0] : null;
|
||||
const value = request.headers[name];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("invalid port");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeOrigins(value) {
|
||||
const values = Array.isArray(value) ? value : String(value || "").split(",");
|
||||
return new Set(values.map(normalizeOrigin).filter(Boolean));
|
||||
}
|
||||
|
||||
function normalizeOrigin(value) {
|
||||
try {
|
||||
return new URL(String(value).trim()).origin.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function validatePublicWsUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must be an absolute WebSocket URL");
|
||||
}
|
||||
if (url.protocol !== "wss:" && !(url.protocol === "ws:" && isLoopbackHost(url.hostname))) {
|
||||
throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must use wss:// outside loopback");
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(value) {
|
||||
const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
||||
}
|
||||
|
||||
function validCloseCode(code) {
|
||||
return code === 1000 || (code >= 1001 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_JSON_BYTES) {
|
||||
reject(httpError(413, "request body too large"));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
||||
resolve(value);
|
||||
} catch {
|
||||
reject(httpError(400, "invalid JSON body"));
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(response, statusCode, body, extraHeaders = {}) {
|
||||
if (response.headersSent) return;
|
||||
const payload = Buffer.from(JSON.stringify(body));
|
||||
response.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": payload.length,
|
||||
"Cache-Control": "no-store",
|
||||
...extraHeaders,
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, status, reason) {
|
||||
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
return Object.assign(new Error(message), { statusCode, expose: statusCode < 500 });
|
||||
}
|
||||
|
||||
function allowedMethod(resource, resourceId) {
|
||||
if (resource === "devices" && resourceId) return "DELETE";
|
||||
if (resource === "devices") return "GET";
|
||||
return "POST";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = new AetherVscodexCloudServer();
|
||||
const address = await server.start();
|
||||
process.stdout.write(`Aether VS Codex sidecar listening on ${address.host}:${address.port}\n`);
|
||||
const shutdown = async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AetherVscodexCloudServer,
|
||||
DeviceStore,
|
||||
EphemeralCredentials,
|
||||
RoomManager,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
AETHER_VSCODEX_ENABLED: "true"
|
||||
AETHER_VSCODEX_INTERNAL_URL: http://vscodex:8788
|
||||
AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
|
||||
AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
|
||||
depends_on:
|
||||
vscodex:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./aether-vscodex/web/dist:/opt/aether/releases/image/frontend/aether-vscodex:ro
|
||||
|
||||
vscodex:
|
||||
build:
|
||||
context: ./aether-vscodex
|
||||
image: ${AETHER_VSCODEX_IMAGE:-aether-vscodex:local}
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8788
|
||||
AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
|
||||
AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
|
||||
AETHER_VSCODEX_ALLOWED_ORIGINS: ${AETHER_VSCODEX_ALLOWED_ORIGINS:?set AETHER_VSCODEX_ALLOWED_ORIGINS}
|
||||
AETHER_VSCODEX_DATA_DIR: /var/lib/aether-vscodex
|
||||
expose:
|
||||
- "8788"
|
||||
volumes:
|
||||
- vscodex_data:/var/lib/aether-vscodex
|
||||
logging:
|
||||
driver: local
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
vscodex_data:
|
||||
@@ -0,0 +1,20 @@
|
||||
# Cloud security model
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- Aether authenticates browser HTTP requests and resolves the user ID. The client never supplies a trusted user ID.
|
||||
- The Node sidecar never receives an Aether access token or JWT signing key.
|
||||
- A VS Code installation receives one revocable device credential. Only its scrypt hash is persisted.
|
||||
- An iframe receives a random, one-time WebSocket ticket with a 60-second lifetime. Tickets are sent in an auth frame, never in a URL.
|
||||
- The embedded UI is trusted, same-origin Aether code. `allow-same-origin` is required by the current integration, so the iframe is not a sandbox boundary for untrusted content even though the parent does not post its JWT into the frame.
|
||||
- Relay state is isolated by `(user_id, device_id)`. A browser ticket and host credential must resolve to the same room.
|
||||
|
||||
## Network boundary
|
||||
|
||||
Run the sidecar on the private Compose network. Do not publish port 8788. Aether gateway is the only public HTTP and WebSocket entry point and authenticates internal API calls with `AETHER_VSCODEX_INTERNAL_TOKEN`.
|
||||
|
||||
`AETHER_VSCODEX_ALLOWED_ORIGINS` must contain the exact public Aether origin when the sidecar binds outside loopback. Public deployments must use HTTPS/WSS.
|
||||
|
||||
## Current scaling limit
|
||||
|
||||
The first release intentionally runs one sidecar replica. Pairing codes, browser tickets, and the live connection directory are process-local. Before adding replicas, move those records to a shared atomic store and add sticky or distributed WebSocket room routing.
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const readline = require("node:readline");
|
||||
|
||||
let threadNumber = 0;
|
||||
let turnNumber = 0;
|
||||
let activeThread = null;
|
||||
let activeTurn = null;
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
const input = readline.createInterface({ input: process.stdin });
|
||||
input.on("line", (line) => {
|
||||
let request;
|
||||
try { request = JSON.parse(line); } catch { return; }
|
||||
if (request.method === "initialize") {
|
||||
send({ id: request.id, result: { userAgent: "fake", codexHome: "/tmp/codex" } });
|
||||
send({ method: "remoteControl/status/changed", params: { status: "disabled" } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "thread/start") {
|
||||
activeThread = `thread-${++threadNumber}`;
|
||||
send({ id: request.id, result: { thread: { id: activeThread }, cwd: request.params?.cwd || "/tmp" } });
|
||||
send({ method: "thread/started", params: { thread: { id: activeThread } } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "turn/start") {
|
||||
activeTurn = `turn-${++turnNumber}`;
|
||||
send({ id: request.id, result: { turn: { id: activeTurn } } });
|
||||
send({ method: "turn/started", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
|
||||
const text = request.params.input?.[0]?.text || "";
|
||||
send({ method: "item/agentMessage/delta", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-1", delta: `echo: ${text}` } });
|
||||
if (text.includes("approve")) {
|
||||
send({ id: 9001, method: "item/commandExecution/requestApproval", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-2", command: "echo approval" } });
|
||||
} else {
|
||||
send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
|
||||
activeTurn = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.method === "turn/steer") {
|
||||
send({ id: request.id, result: { turn: { id: activeTurn } } });
|
||||
send({ method: "item/agentMessage/delta", params: { delta: `steered: ${request.params.input?.[0]?.text || ""}` } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "turn/interrupt") {
|
||||
send({ id: request.id, result: {} });
|
||||
send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: request.params.turnId } } });
|
||||
activeTurn = null;
|
||||
return;
|
||||
}
|
||||
if (request.id === 9001 && (request.result || request.error)) {
|
||||
send({ method: "item/agentMessage/delta", params: { delta: `approval response: ${JSON.stringify(request.result || request.error)}` } });
|
||||
send({ method: "turn/completed", params: { threadId: activeThread, turn: { id: activeTurn } } });
|
||||
activeTurn = null;
|
||||
}
|
||||
});
|
||||
Generated
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "aether-vscodex",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aether-vscodex",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "aether-vscodex",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Synchronous VS Code Codex mirroring and asynchronous Codex control for local Web and Aether",
|
||||
"type": "commonjs",
|
||||
"main": "relay/server.js",
|
||||
"scripts": {
|
||||
"start": "node relay/server.js",
|
||||
"start:cloud": "node cloud/server.js",
|
||||
"build:web": "npm --prefix web run build",
|
||||
"build:extension": "npm --prefix vscode-extension run build",
|
||||
"build": "npm run build:web && npm run build:extension",
|
||||
"test": "node --test test/*.test.js",
|
||||
"test:web": "npm --prefix web test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (!root || !root.document) return;
|
||||
|
||||
const bridge = api.createAetherEmbedBridge(root);
|
||||
root.AetherVscodexEmbed = bridge;
|
||||
if (bridge.active) bridge.start();
|
||||
})(typeof window === "object" ? window : undefined, function () {
|
||||
"use strict";
|
||||
|
||||
const VERSION = 1;
|
||||
const PREFIX = "aether-vscodex/";
|
||||
const INBOUND_TYPES = new Set(["connect", "context", "disconnect", "error"]);
|
||||
|
||||
function isAetherEmbed(locationLike) {
|
||||
try {
|
||||
return new URLSearchParams(locationLike?.search || "").get("embed") === "aether";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTheme(value) {
|
||||
const theme = String(value || "").trim().toLowerCase();
|
||||
return theme === "dark" || theme === "light" ? theme : "system";
|
||||
}
|
||||
|
||||
function createAetherEmbedBridge(windowLike) {
|
||||
const active = isAetherEmbed(windowLike.location);
|
||||
const listeners = new Map();
|
||||
const pending = new Map();
|
||||
let started = false;
|
||||
|
||||
const emit = (name, payload) => {
|
||||
for (const listener of listeners.get(name) || []) listener(payload);
|
||||
};
|
||||
|
||||
const post = (type, payload = {}) => {
|
||||
if (!active || windowLike.parent === windowLike) return false;
|
||||
windowLike.parent.postMessage({ v: VERSION, type: `${PREFIX}${type}`, ...payload }, windowLike.location.origin);
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyContext = (payload) => {
|
||||
if (payload.locale && windowLike.VscodexI18n?.setLocale) {
|
||||
windowLike.VscodexI18n.setLocale(payload.locale, { persist: false });
|
||||
}
|
||||
const theme = normalizeTheme(payload.theme);
|
||||
const documentElement = windowLike.document?.documentElement;
|
||||
if (documentElement) {
|
||||
if (theme === "system") delete documentElement.dataset.theme;
|
||||
else documentElement.dataset.theme = theme;
|
||||
documentElement.style.colorScheme = theme === "system" ? "" : theme;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (event) => {
|
||||
if (!active || event.origin !== windowLike.location.origin || event.source !== windowLike.parent) return;
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object" || message.v !== VERSION || typeof message.type !== "string") return;
|
||||
if (!message.type.startsWith(PREFIX)) return;
|
||||
const name = message.type.slice(PREFIX.length);
|
||||
if (!INBOUND_TYPES.has(name)) return;
|
||||
if (name === "connect" || name === "context") applyContext(message);
|
||||
if (!(listeners.get(name)?.size)) pending.set(name, message);
|
||||
emit(name, message);
|
||||
};
|
||||
|
||||
return {
|
||||
active,
|
||||
version: VERSION,
|
||||
start() {
|
||||
if (!active || started) return;
|
||||
started = true;
|
||||
windowLike.document.body?.classList.add("embed-aether");
|
||||
windowLike.addEventListener("message", handleMessage);
|
||||
post("ready");
|
||||
},
|
||||
stop() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
windowLike.removeEventListener("message", handleMessage);
|
||||
listeners.clear();
|
||||
pending.clear();
|
||||
},
|
||||
on(name, listener) {
|
||||
if (!INBOUND_TYPES.has(name) || typeof listener !== "function") return () => undefined;
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
listeners.get(name).add(listener);
|
||||
if (pending.has(name)) {
|
||||
const message = pending.get(name);
|
||||
pending.delete(name);
|
||||
listener(message);
|
||||
}
|
||||
return () => listeners.get(name)?.delete(listener);
|
||||
},
|
||||
post,
|
||||
requestTicket(payload = {}) {
|
||||
return post("request-ticket", payload);
|
||||
},
|
||||
reportState(state, payload = {}) {
|
||||
return post("state", { state, ...payload });
|
||||
},
|
||||
_handleMessage: handleMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return { createAetherEmbedBridge, isAetherEmbed, normalizeTheme };
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = factory(root);
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root?.document) root.VscodexI18n = api;
|
||||
})(typeof window === "object" ? window : undefined, function (root) {
|
||||
"use strict";
|
||||
|
||||
const STORAGE_KEY = "aether-vscodex.locale";
|
||||
const SUPPORTED = new Set(["zh-CN", "en-US"]);
|
||||
const EN = Object.freeze({
|
||||
"本地模式": "Local mode",
|
||||
"独立模式": "Standalone mode",
|
||||
"云端模式": "Cloud mode",
|
||||
"控制模式": "Control mode",
|
||||
"同步": "Sync",
|
||||
"异步": "Async",
|
||||
"同步模式跟随 VS Code 当前会话": "Sync mode follows the current VS Code conversation",
|
||||
"异步模式可独立管理会话": "Async mode manages conversations independently",
|
||||
"正在切换控制模式": "Switching control mode",
|
||||
"控制模式已切换": "Control mode switched",
|
||||
"控制模式切换失败": "Unable to switch control mode",
|
||||
"当前任务或请求完成后才能切换控制模式": "The control mode can be changed after the current task or request finishes",
|
||||
"同步模式下会话管理由 VS Code 控制": "VS Code controls conversation navigation in sync mode",
|
||||
"当前模式不支持修改会话设置": "The current mode does not support changing conversation settings",
|
||||
"本机连接(无需 token)": "Local connection (no token required)",
|
||||
"本机模式无需填写;认证模式再填写": "No token is needed locally; enter one only for authenticated mode",
|
||||
"访问 token(认证模式)": "Access token (authenticated mode)",
|
||||
"粘贴 relay 启动时打印的 token": "Paste the token printed when the relay started",
|
||||
"本地连接无需 token": "No token is needed for a local connection",
|
||||
"编辑外部文件和联网时始终询问": "Always ask before editing external files or using the network",
|
||||
"不限制联网或文件访问": "Allow unrestricted network and file access",
|
||||
"查看请求数据": "View request data",
|
||||
"查看上下文用量": "View context usage",
|
||||
"创建新会话": "New conversation",
|
||||
"打开会话历史": "Open conversation history",
|
||||
"待处理的 Codex 请求": "Pending Codex requests",
|
||||
"当前会话": "Current conversation",
|
||||
"当前模型": "Current model",
|
||||
"切换模型": "Change model",
|
||||
"等待 VS Code 主机": "Waiting for VS Code host",
|
||||
"等待连接": "Waiting for connection",
|
||||
"对话内容": "Conversation",
|
||||
"发送 JSON": "Send JSON",
|
||||
"发送后续指令": "Send follow-up",
|
||||
"发送消息": "Send message",
|
||||
"返回会话列表": "Back to conversations",
|
||||
"返回模型强度": "Back to model effort",
|
||||
"高级": "Advanced",
|
||||
"简洁": "Simple",
|
||||
"更多操作": "More actions",
|
||||
"更高效": "More efficient",
|
||||
"更智能": "More capable",
|
||||
"工作目录": "Working directory",
|
||||
"工作区": "Workspace",
|
||||
"工作区写入": "Workspace write",
|
||||
"回到最新消息": "Jump to latest message",
|
||||
"正在工作,回到最新消息": "Working, jump to latest message",
|
||||
"会话历史": "Conversation history",
|
||||
"会话设置": "Conversation settings",
|
||||
"仅本次 turn": "This turn only",
|
||||
"仅查看文件,不修改工作区": "View files without changing the workspace",
|
||||
"仅对可能不安全的操作询问": "Ask only for potentially unsafe actions",
|
||||
"拒绝": "Deny",
|
||||
"可用会话": "Available conversations",
|
||||
"连接设置": "Connection settings",
|
||||
"留空使用默认模型": "Leave empty to use the default model",
|
||||
"模式": "Mode",
|
||||
"模型": "Model",
|
||||
"模型与推理强度": "Model and reasoning effort",
|
||||
"默认": "Default",
|
||||
"启动新 thread": "Start new thread",
|
||||
"强度": "Effort",
|
||||
"切换模型与推理强度": "Change model and reasoning effort",
|
||||
"清除搜索": "Clear search",
|
||||
"清空当前输出": "Clear current output",
|
||||
"清空对话": "Clear conversation",
|
||||
"取消": "Cancel",
|
||||
"权限设置": "Permission settings",
|
||||
"确认": "Confirm",
|
||||
"确认完全访问": "Confirm full access",
|
||||
"沙箱": "Sandbox",
|
||||
"上下文用量": "Context usage",
|
||||
"设置": "Settings",
|
||||
"审批策略": "Approval policy",
|
||||
"使用 config.toml 中的权限": "Use permissions from config.toml",
|
||||
"使用左右方向键调整强度": "Use the left and right arrow keys to adjust effort",
|
||||
"授权范围": "Authorization scope",
|
||||
"授权与输入": "Approvals and input",
|
||||
"刷新会话列表": "Refresh conversations",
|
||||
"搜索最近会话": "Search recent conversations",
|
||||
"提交后续变更要求": "Ask for follow-up changes",
|
||||
"添加工作区上下文": "Add workspace context",
|
||||
"添加文件": "Add files",
|
||||
"添加文件及更多内容": "Add files and more",
|
||||
"添加照片": "Add photos",
|
||||
"推理强度": "Reasoning effort",
|
||||
"完全访问": "Full access",
|
||||
"完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。": "Full access lets Codex run commands, use the internet, and edit files outside the workspace.",
|
||||
"网页搜索": "Web search",
|
||||
"未认证": "Unauthenticated",
|
||||
"显示 Codex": "Show Codex",
|
||||
"修改权限": "Change permissions",
|
||||
"需要时询问": "Ask when needed",
|
||||
"已附着当前会话": "Attached to current conversation",
|
||||
"隐藏面板": "Hide panel",
|
||||
"由 Codex 审批": "Let Codex decide",
|
||||
"允许": "Allow",
|
||||
"允许一次": "Allow once",
|
||||
"暂无待处理请求": "No pending requests",
|
||||
"暂无用量数据": "No usage data",
|
||||
"展开面板": "Expand panel",
|
||||
"正在连接": "Connecting",
|
||||
"只读": "Read only",
|
||||
"中断当前 turn": "Interrupt current turn",
|
||||
"重新同步": "Resync",
|
||||
"子代理": "Subagent",
|
||||
"自定义": "Custom",
|
||||
"最近会话": "Recent conversations",
|
||||
"Codex 消息": "Codex messages",
|
||||
"JSON 响应": "JSON response",
|
||||
"语言": "Language",
|
||||
"中文": "Chinese",
|
||||
"跟随浏览器": "Use browser language",
|
||||
"正在连接云端会话": "Connecting to cloud conversation",
|
||||
"正在等待云端连接": "Waiting for cloud connection",
|
||||
"云端连接已断开": "Cloud connection disconnected",
|
||||
"云端连接配置无效": "Invalid cloud connection configuration",
|
||||
"云端连接地址必须与当前页面同源": "The cloud connection URL must be same-origin",
|
||||
"正在获取新的连接凭证": "Requesting new connection credentials",
|
||||
"父页面已断开连接": "Disconnected by the parent page",
|
||||
"当前 relay 需要 token": "This relay requires a token",
|
||||
"WebSocket 未连接": "WebSocket is not connected",
|
||||
"连接中": "Connecting",
|
||||
"同步中": "Syncing",
|
||||
"已连接": "Connected",
|
||||
"认证失败,准备重连": "Authentication failed; preparing to reconnect",
|
||||
"准备重连": "Preparing to reconnect",
|
||||
"重连中": "Reconnecting",
|
||||
"收到无法解析的 relay 消息": "Received an unreadable relay message",
|
||||
"等待 relay 连接": "Waiting for relay connection",
|
||||
"等待 VS Code 主机连接": "Waiting for VS Code host",
|
||||
"VS Code 主机未连接": "VS Code host is disconnected",
|
||||
"等待 VS Code 伴随扩展连接": "Waiting for the VS Code companion extension",
|
||||
"VS Code 伴随扩展未连接": "VS Code companion extension is disconnected",
|
||||
"等待在 VS Code 中打开 Codex 会话": "Open a Codex conversation in VS Code to continue",
|
||||
"会话已关闭": "Conversation closed",
|
||||
"VS Code 会话已关闭": "VS Code conversation closed",
|
||||
"会话操作失败": "Conversation operation failed",
|
||||
"当前任务结束或请求处理后才能切换": "You can switch after the current task or request finishes",
|
||||
"目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它": "The target conversation did not return a VS Code snapshot. Open it in the official Codex panel first.",
|
||||
"当前 relay 版本不支持此会话操作,请重启 relay": "This relay version does not support the conversation action. Restart the relay.",
|
||||
"正在读取会话…": "Loading conversations...",
|
||||
"正在切换会话…": "Switching conversation...",
|
||||
"无法读取会话": "Unable to load conversations",
|
||||
"没有匹配的会话": "No matching conversations",
|
||||
"没有可附加的会话": "No attachable conversations",
|
||||
"没有可控制的会话": "No controllable conversations",
|
||||
"正在切换": "Switching",
|
||||
"未打开": "Not open",
|
||||
"当前": "Current",
|
||||
"可切换": "Available",
|
||||
"会话": "Conversation",
|
||||
"当前角色不能创建会话": "Your current role cannot create conversations",
|
||||
"正在创建新会话": "Creating a new conversation",
|
||||
"无法创建新会话": "Unable to create a new conversation",
|
||||
"当前任务仍在运行或等待授权,暂不能切换": "The current task is running or awaiting approval, so it cannot be switched yet",
|
||||
"会话切换失败": "Conversation switch failed",
|
||||
"正在确认会话": "Confirming conversation",
|
||||
"正在加载会话": "Loading conversation",
|
||||
"会话已切换": "Conversation switched",
|
||||
"正在更新模型设置": "Updating model settings",
|
||||
"模型设置已更新": "Model settings updated",
|
||||
"无法更新模型设置": "Unable to update model settings",
|
||||
"已停止": "Stopped",
|
||||
"成功": "Succeeded",
|
||||
"无输出": "No output",
|
||||
"等待输出…": "Waiting for output...",
|
||||
"执行步骤": "Action",
|
||||
"正在读取文件": "Reading files",
|
||||
"读取完成": "Finished reading",
|
||||
"已读取文件运行了命令": "Read files and ran a command",
|
||||
"已读取文件": "Read files",
|
||||
"编辑了文件": "Edited files",
|
||||
"已完成计划": "Completed plan",
|
||||
"读取文件失败": "Failed to read files",
|
||||
"已停止读取文件": "Stopped reading files",
|
||||
"读取文件": "Read files",
|
||||
"已运行命令": "Ran command",
|
||||
"正在运行命令": "Running command",
|
||||
"正在思考": "Thinking",
|
||||
"正在制定计划": "Creating a plan",
|
||||
"正在编辑文件": "Editing files",
|
||||
"正在处理": "Working",
|
||||
"已完成思考": "Finished thinking",
|
||||
"计划完成": "Plan completed",
|
||||
"文件编辑完成": "Finished editing files",
|
||||
"工作说明": "Progress update",
|
||||
"计划": "Plan",
|
||||
"文件变更": "File changes",
|
||||
"等待授权": "Waiting for approval",
|
||||
"正在生成": "Generating",
|
||||
"已中断": "Interrupted",
|
||||
"失败": "Failed",
|
||||
"已完成": "Completed",
|
||||
"正在工作": "Working",
|
||||
"正在等待你的回答": "Waiting for your answer",
|
||||
"正在搜索网页": "Searching the web",
|
||||
"执行失败": "Action failed",
|
||||
"处理中": "Working",
|
||||
"思考": "Reasoning",
|
||||
"编辑文件": "Edit files",
|
||||
"思考中": "Thinking",
|
||||
"编辑中": "Editing",
|
||||
"进行中": "In progress",
|
||||
"异常": "Error",
|
||||
"未读": "Unread",
|
||||
"本地会话": "Local conversation",
|
||||
"默认拒绝,请明确允许": "Denied by default; allow explicitly",
|
||||
"需要远程确认或输入": "Remote confirmation or input is required",
|
||||
"允许运行命令?": "Allow this command?",
|
||||
"允许修改文件?": "Allow file changes?",
|
||||
"需要扩大权限": "Additional permissions required",
|
||||
"Codex 需要你的回答": "Codex needs your answer",
|
||||
"需要外部服务确认": "External service confirmation required",
|
||||
"Codex 请求确认": "Codex requests confirmation",
|
||||
"高风险": "High risk",
|
||||
"低风险": "Low risk",
|
||||
"需确认": "Confirmation required",
|
||||
"请输入": "Enter a response",
|
||||
"提交回答": "Submit answer",
|
||||
"发送自定义响应": "Send custom response",
|
||||
"自定义响应不是有效 JSON": "The custom response is not valid JSON",
|
||||
"响应不是有效 JSON": "The response is not valid JSON",
|
||||
"远程参与者拒绝": "Denied by remote participant",
|
||||
"状态": "Status",
|
||||
"命令": "Command",
|
||||
"详情": "Details",
|
||||
"复制消息": "Copy message",
|
||||
"复制命令": "Copy command",
|
||||
"复制输出": "Copy output",
|
||||
"未知": "Unknown",
|
||||
"未知错误": "Unknown error",
|
||||
"已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。": "Attached to the current VS Code Codex conversation. Messages, output, and approvals all return to that conversation.",
|
||||
"当前为独立 app-server 模式。": "Currently using standalone app-server mode.",
|
||||
"已附着现有会话": "Attached to existing conversation",
|
||||
"通用 Codex 模型": "General-purpose Codex model",
|
||||
"平衡速度与推理": "Balanced speed and reasoning",
|
||||
"可用模型": "Available model",
|
||||
"极低": "Minimal",
|
||||
"轻度": "Low",
|
||||
"标准": "Medium",
|
||||
"深度": "High",
|
||||
"极高": "Extra high",
|
||||
"最大": "Maximum",
|
||||
"此模型使用默认推理强度": "This model uses its default reasoning effort",
|
||||
"返回简洁模型选择": "Return to simple model selection",
|
||||
"显示高级模型选项": "Show advanced model options",
|
||||
"自定义权限由 config.toml 管理": "Custom permissions are managed by config.toml",
|
||||
"正在等待指示": "Waiting for instructions",
|
||||
"正在工作": "Working",
|
||||
"命令输出": "Command output",
|
||||
"工具输出": "Tool output",
|
||||
"发送 Steer": "Send steer",
|
||||
"会话切换失败,已恢复原会话": "Conversation switch failed; restored the previous conversation",
|
||||
"(空消息)": "(empty message)",
|
||||
"今天": "Today",
|
||||
"昨天": "Yesterday",
|
||||
"未完成": "Not completed",
|
||||
"步骤": "Step",
|
||||
"查看图像": "View image",
|
||||
"等待输入": "Waiting for input",
|
||||
"读取文件运行命令失败": "Failed to read files and run a command",
|
||||
"发送输入": "Send input",
|
||||
"工具": "Tool",
|
||||
"工具失败": "Tool failed",
|
||||
"正在搜索": "Searching",
|
||||
"你停止了工作": "You stopped working",
|
||||
"关闭子代理": "Close subagent",
|
||||
"恢复子代理": "Resume subagent",
|
||||
"启动子代理": "Start subagent",
|
||||
"搜索": "Search",
|
||||
"文件": "File",
|
||||
"新会话已在 VS Code 中打开": "The new conversation opened in VS Code",
|
||||
"事件窗口已过期,请以当前快照为准": "The event window expired; the current snapshot is authoritative",
|
||||
"执行状态未知,请等待主机恢复": "Execution status is unknown; wait for the host to recover",
|
||||
"文件已截断": "File truncated",
|
||||
"已拒绝": "Denied",
|
||||
"已开始工作": "Started working",
|
||||
"已添加工作区上下文": "Added workspace context",
|
||||
"已添加网页搜索": "Added web search",
|
||||
"运行命令": "Run command",
|
||||
"整理上下文": "Compacting context",
|
||||
"正在切换会话": "Switching conversation",
|
||||
"MCP 工具": "MCP tool",
|
||||
" · @ 可标记代理": " · @ to mention agents",
|
||||
});
|
||||
|
||||
const EN_PATTERNS = Object.freeze([
|
||||
[/^用时 1分钟(\d+)秒$/, "Worked for 1m{1}s"],
|
||||
[/^用时 (\d+)分(\d+)秒$/, "Worked for {1}m{2}s"],
|
||||
[/^用时 1分钟$/, "Worked for 1m"],
|
||||
[/^用时 (\d+)分$/, "Worked for {1}m"],
|
||||
[/^用时 (\d+)秒$/, "Worked for {1}s"],
|
||||
[/^用时 (\d+)毫秒$/, "Worked for {1}ms"],
|
||||
[/^用时\s+(.+)$/, "Worked for {1}"],
|
||||
[/^已思考 1分钟(\d+)秒$/, "Thought for 1m{1}s"],
|
||||
[/^已思考 (\d+)分(\d+)秒$/, "Thought for {1}m{2}s"],
|
||||
[/^已思考 (\d+)秒$/, "Thought for {1}s"],
|
||||
[/^已思考\s+(.+)$/, "Thought for {1}"],
|
||||
[/^退出码\s+(.+)$/, "Exit code {1}"],
|
||||
[/^正在读取\s+(.+)$/, "Reading {1}", [1]],
|
||||
[/^已读取\s+(.+)$/, "Read {1}", [1]],
|
||||
[/^读取失败\s*·\s*(.+)$/, "Failed to read {1}", [1]],
|
||||
[/^已停止读取\s+(.+)$/, "Stopped reading {1}", [1]],
|
||||
[/^读取\s+(.+)$/, "Read {1}", [1]],
|
||||
[/^已读取这些内容\s*·\s*(\d+)\s*个文件(.*)$/, "Read these items · {1} files{2}"],
|
||||
[/^已在\s+(.+)\s+内运行\s+(.+)$/, "Ran {2} in {1}", [2]],
|
||||
[/^命令运行失败\s*·\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Command failed · {1} · {2}", [1]],
|
||||
[/^命令运行失败\s*·\s*(.+)$/, "Command failed · {1}", [1]],
|
||||
// Renderer-owned disclosure labels. Keep the captured command/model text
|
||||
// intact; only the surrounding UI words are localized.
|
||||
[/^命令\s*·\s*(.+)$/, "Command · {1}", [1]],
|
||||
[/^已工具\s*·\s*(.+)$/, "Tool completed · {1}"],
|
||||
[/^当前模型\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大),切换模型$/, "Current model: {1} {2}. Change model", [1]],
|
||||
[/^已停止\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Stopped {1} · {2}", [1]],
|
||||
[/^已运行\s*(.+)$/, "Ran {1}", [1]],
|
||||
[/^命令运行失败\s*(.*)$/, "Command failed{1}", [1]],
|
||||
[/^命令:\s*(.+?)(执行状态未知,请等待主机恢复)$/, "Command: {1} (execution status unknown; wait for the host to recover)", [1]],
|
||||
[/^命令:\s*(.+)$/, "Command: {1}", [1]],
|
||||
[/^已停止\s*(.+)$/, "Stopped {1}", [1]],
|
||||
[/^正在运行\s+(.+)$/, "Running {1}", [1]],
|
||||
[/^(.+?)\s*·\s*失败$/, "{1} · Failed"],
|
||||
[/^(.+?)\s*·\s*已中断$/, "{1} · Interrupted"],
|
||||
[/^(.+)\s+失败$/, "{1} failed"],
|
||||
[/^编辑了文件\s*·\s*(.+)$/, "Edited files · {1}"],
|
||||
[/^已完成计划\s*·\s*(.+)$/, "Completed plan · {1}"],
|
||||
[/^(\d+)\/(\d+)\s*个会话$/, "{1}/{2} conversations"],
|
||||
[/^(\d+)\s*个会话$/, "{1} conversations"],
|
||||
[/^会话\s+(.+)$/, "Conversation {1}", [1]],
|
||||
[/^工作区\s*·\s*(.+)$/, "Workspace · {1}", [1]],
|
||||
[/^昨天\s+(.+)$/, "Yesterday {1}", [1]],
|
||||
[/^正在切换到「(.+)」…?$/, "Switching to “{1}”...", [1]],
|
||||
[/^你在\s+(.+)\s+后停止了$/, "You stopped after {1}"],
|
||||
[/^执行失败\s*·\s*(.+)$/, "Action failed · {1}"],
|
||||
[/^新会话创建失败:(.+)$/, "Unable to create a new conversation: {1}", [1]],
|
||||
[/^模型设置更新失败:(.+)$/, "Unable to update model settings: {1}", [1]],
|
||||
[/^(.+)(执行状态未知,请等待主机恢复)$/, "{1} (execution status unknown; wait for the host to recover)", [1]],
|
||||
[/^(.+) 完成$/, "{1} completed", [1]],
|
||||
[/^请求 #(.+) 已提交$/, "Request #{1} submitted"],
|
||||
[/^请求 #(.+) 已发送,等待 VS Code 主机确认$/, "Request #{1} sent; waiting for the VS Code host"],
|
||||
[/^无法读取 (.+)$/, "Unable to read {1}", [1]],
|
||||
[/^\[图片附件:(.+)\]$/, "[Image attachment: {1}]", [1]],
|
||||
[/^(.+) 已开始工作$/, "{1} started working", [1]],
|
||||
[/^(.+) 已完成$/, "{1} completed", [1]],
|
||||
[/^(.+) 已中断$/, "{1} interrupted", [1]],
|
||||
[/^…(文件已截断)$/, "... (file truncated)"],
|
||||
[/^当前模型\s+(.+),切换模型$/, "Current model: {1}. Change model", [1]],
|
||||
[/^切换模型(当前\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大))$/, "Change model (current: {1} {2})", [1]],
|
||||
[/^切换模型(当前\s+(.+))$/, "Change model (current: {1})", [1]],
|
||||
[/^修改权限,当前为(.+)$/, "Change permissions. Current: {1}"],
|
||||
[/^修改权限(当前:(.+))$/, "Change permissions (current: {1})"],
|
||||
[/^上下文已使用\s*(\d+)%(剩余\s*(\d+)%)$/, "Context used: {1}% ({2}% remaining)"],
|
||||
[/^(\d+)%\s*已使用$/, "{1}% used"],
|
||||
[/^剩余\s+(.+)\s+tokens$/, "{1} tokens remaining"],
|
||||
[/^当前上下文\s+(.+)\s+tokens$/, "Current context: {1} tokens"],
|
||||
[/^最近请求\s+(.+)\s+tokens$/, "Latest request: {1} tokens"],
|
||||
[/^累计\s+(.+)\s+tokens$/, "Total: {1} tokens"],
|
||||
[/^使用\s+(.+)$/, "Using {1}", [1]],
|
||||
[/^已用时\s+(.+)$/, "Elapsed: {1}"],
|
||||
[/^(\d+)\s*个后台代理(.*)$/, "{1} background agents{2}"],
|
||||
[/^(\d+)毫秒$/, "{1}ms"],
|
||||
[/^(\d+)秒$/, "{1}s"],
|
||||
[/^1分钟(\d+)秒$/, "1m{1}s"],
|
||||
[/^(\d+)分(\d+)秒$/, "{1}m{2}s"],
|
||||
[/^1分钟(\d+秒)?$/, "1m{1}"],
|
||||
[/^(\d+)分(\d+秒)?$/, "{1}m{2}"],
|
||||
]);
|
||||
const ZH = Object.freeze(Object.fromEntries(Object.entries(EN).map(([source, translated]) => [translated, source])));
|
||||
|
||||
let currentLocale = "zh-CN";
|
||||
let observer = null;
|
||||
const textSources = new WeakMap();
|
||||
const textRendered = new WeakMap();
|
||||
const attributeSources = new WeakMap();
|
||||
const attributeRendered = new WeakMap();
|
||||
|
||||
function normalizeLocale(value) {
|
||||
const locale = String(value || "").trim().replace("_", "-").toLowerCase();
|
||||
return locale.startsWith("zh") ? "zh-CN" : "en-US";
|
||||
}
|
||||
|
||||
function embeddedMode() {
|
||||
if (root?.AetherVscodexEmbed?.active) return true;
|
||||
try { return new URLSearchParams(root?.location?.search || "").get("embed") === "aether"; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function interpolate(template, values) {
|
||||
return String(template).replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? "");
|
||||
}
|
||||
|
||||
function translate(value, locale = currentLocale, depth = 0) {
|
||||
const source = String(value ?? "");
|
||||
if (!source) return source;
|
||||
if (normalizeLocale(locale) === "zh-CN") return ZH[source] || source;
|
||||
if (Object.prototype.hasOwnProperty.call(EN, source)) return EN[source];
|
||||
for (const [pattern, template, rawIndexes] of EN_PATTERNS) {
|
||||
const match = source.match(pattern);
|
||||
if (match) {
|
||||
const translatedMatch = match.map((part, index) => index === 0
|
||||
? part
|
||||
: rawIndexes?.includes(index) ? part
|
||||
: depth < 6 ? translate(part, locale, depth + 1) : (EN[part] || part));
|
||||
return interpolate(template, translatedMatch);
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function shouldSkipTextNode(node) {
|
||||
const parent = node?.parentElement;
|
||||
return Boolean(parent?.closest?.("code, pre, .message-body, .request-summary, .request-questions, .request-json, .request-command, .diff-output, .terminal-output, .session-option-title, .subagent-name, .subagent-summary-label"));
|
||||
}
|
||||
|
||||
function translateTextNode(node) {
|
||||
if (!node || shouldSkipTextNode(node)) return;
|
||||
const current = node.nodeValue;
|
||||
const previousRendered = textRendered.get(node);
|
||||
if (!textSources.has(node) || current !== previousRendered) textSources.set(node, current);
|
||||
const source = textSources.get(node);
|
||||
const leading = source.match(/^\s*/)?.[0] || "";
|
||||
const trailing = source.match(/\s*$/)?.[0] || "";
|
||||
const core = source.slice(leading.length, source.length - trailing.length);
|
||||
if (!core) return;
|
||||
const translated = translate(core);
|
||||
const rendered = `${leading}${translated}${trailing}`;
|
||||
textRendered.set(node, rendered);
|
||||
if (rendered !== current) node.nodeValue = rendered;
|
||||
}
|
||||
|
||||
function translateAttributes(element) {
|
||||
if (!element?.getAttribute || element.closest?.(".message-body, pre, code")) return;
|
||||
let sources = attributeSources.get(element);
|
||||
let renderedValues = attributeRendered.get(element);
|
||||
if (!sources) { sources = new Map(); attributeSources.set(element, sources); }
|
||||
if (!renderedValues) { renderedValues = new Map(); attributeRendered.set(element, renderedValues); }
|
||||
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) {
|
||||
if (!element.hasAttribute(attribute)) continue;
|
||||
const current = element.getAttribute(attribute);
|
||||
if (!sources.has(attribute) || current !== renderedValues.get(attribute)) sources.set(attribute, current);
|
||||
const source = sources.get(attribute);
|
||||
const translated = translate(source);
|
||||
renderedValues.set(attribute, translated);
|
||||
if (translated !== current) element.setAttribute(attribute, translated);
|
||||
}
|
||||
}
|
||||
|
||||
function translateTree(node) {
|
||||
if (!root?.document || !node) return;
|
||||
if (node.nodeType === 3) {
|
||||
translateTextNode(node);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
|
||||
if (node.nodeType === 1) translateAttributes(node);
|
||||
const walker = root.document.createTreeWalker(node, root.NodeFilter.SHOW_ELEMENT | root.NodeFilter.SHOW_TEXT);
|
||||
for (let current = walker.nextNode(); current; current = walker.nextNode()) {
|
||||
if (current.nodeType === 3) translateTextNode(current);
|
||||
else translateAttributes(current);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDocument() {
|
||||
if (!root?.document) return;
|
||||
root.document.documentElement.lang = currentLocale;
|
||||
translateTree(root.document.body);
|
||||
const selector = root.document.getElementById("localeSelect");
|
||||
if (selector && selector.value !== currentLocale) selector.value = currentLocale;
|
||||
}
|
||||
|
||||
function setLocale(value, options = {}) {
|
||||
currentLocale = SUPPORTED.has(value) ? value : normalizeLocale(value);
|
||||
if (options.persist !== false && root?.localStorage && !embeddedMode()) {
|
||||
try { root.localStorage.setItem(STORAGE_KEY, currentLocale); } catch { /* storage may be disabled */ }
|
||||
}
|
||||
applyDocument();
|
||||
if (root?.CustomEvent) root.dispatchEvent?.(new root.CustomEvent("aether-vscodex:locale", { detail: { locale: currentLocale } }));
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
function initialLocale() {
|
||||
if (embeddedMode()) return normalizeLocale(root?.navigator?.language);
|
||||
try {
|
||||
const saved = root?.localStorage?.getItem(STORAGE_KEY);
|
||||
if (SUPPORTED.has(saved)) return saved;
|
||||
} catch { /* storage may be disabled */ }
|
||||
return normalizeLocale(root?.navigator?.language);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!root?.document) return;
|
||||
currentLocale = initialLocale();
|
||||
applyDocument();
|
||||
if (typeof root.MutationObserver === "function" && !observer) {
|
||||
observer = new root.MutationObserver((records) => {
|
||||
if (currentLocale === "zh-CN") return;
|
||||
for (const record of records) {
|
||||
if (record.type === "characterData") translateTextNode(record.target);
|
||||
else if (record.type === "attributes") translateAttributes(record.target);
|
||||
else for (const node of record.addedNodes) translateTree(node);
|
||||
}
|
||||
});
|
||||
observer.observe(root.document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["title", "aria-label", "placeholder", "data-placeholder"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
locale: () => currentLocale,
|
||||
normalizeLocale,
|
||||
setLocale,
|
||||
start,
|
||||
t: (value) => translate(value),
|
||||
translate,
|
||||
translateTree,
|
||||
messages: { "zh-CN": Object.freeze({}), "en-US": EN },
|
||||
};
|
||||
|
||||
if (root?.document) {
|
||||
if (root.document.readyState === "loading") root.document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
else start();
|
||||
}
|
||||
return api;
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Codex</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body class="codex-app local-no-auth">
|
||||
<div class="codex-panel">
|
||||
<div class="connection" aria-live="polite" hidden>
|
||||
<span id="connectionDot" class="dot offline"></span>
|
||||
<span id="connectionText">正在连接</span>
|
||||
<span id="roleBadge" class="badge">未认证</span>
|
||||
</div>
|
||||
<main class="chat-shell">
|
||||
<section class="chat-header" aria-label="当前会话">
|
||||
<div class="thread-heading">
|
||||
<button id="backButton" class="icon-button header-back-button" type="button" data-panel-action="back" title="返回会话列表" aria-label="返回会话列表" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.75 3.5 5.25 8l4.5 4.5M5.5 8h6.25" /></svg>
|
||||
</button>
|
||||
<button id="sessionPickerButton" class="thread-picker-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="打开会话历史" aria-label="打开会话历史" disabled>
|
||||
<h2 id="threadTitle">Codex</h2>
|
||||
</button>
|
||||
<span id="appState" class="status-text" aria-live="polite">等待 VS Code 主机</span>
|
||||
</div>
|
||||
<div class="thread-actions">
|
||||
<button class="icon-button" type="button" data-panel-action="menu" title="更多操作" aria-label="更多操作">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="3" cy="8" r="1" /><circle cx="8" cy="8" r="1" /><circle cx="13" cy="8" r="1" /></svg>
|
||||
</button>
|
||||
<button id="historyButton" class="icon-button header-history-button" type="button" data-panel-action="history" title="会话历史" aria-label="会话历史" hidden>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></svg>
|
||||
</button>
|
||||
<button class="icon-button" type="button" data-panel-action="settings" title="设置" aria-label="设置">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.7 2h2.6l.4 1.6c.4.2.8.4 1.2.7l1.6-.6 1.3 2.2-1.2 1.1a5 5 0 0 1 0 1.4l1.2 1.1-1.3 2.2-1.6-.6c-.4.3-.8.5-1.2.7L9.3 14H6.7l-.4-1.6a5 5 0 0 1-1.2-.7l-1.6.6-1.3-2.2 1.2-1.1a5 5 0 0 1 0-1.4L2.2 6l1.3-2.2 1.6.6c.4-.3.8-.5 1.2-.7L6.7 2Z" /><circle cx="8" cy="8" r="1.7" /></svg>
|
||||
</button>
|
||||
<button id="newSessionButton" class="icon-button new-session-button" type="button" data-panel-action="new-session" title="创建新会话" aria-label="创建新会话" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.25 3.25h5.5a1.5 1.5 0 0 1 1.5 1.5v2.5" /><path d="M3.25 3.25v9.5h6" /><path d="m8.2 11.35 4.55-4.55 1.25 1.25-4.55 4.55-2 .5Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div id="panelMenu" class="panel-popover panel-menu" hidden>
|
||||
<button type="button" data-menu-action="sessions" hidden>最近会话</button>
|
||||
<button type="button" data-menu-action="clear">清空当前输出</button>
|
||||
<button type="button" data-menu-action="refresh">重新同步</button>
|
||||
<button type="button" data-menu-action="expand">展开面板</button>
|
||||
<button type="button" data-menu-action="close">隐藏面板</button>
|
||||
</div>
|
||||
<div id="detailsPopover" class="panel-popover details-popover settings-popover" hidden role="dialog" aria-label="设置">
|
||||
<div class="popover-title">设置</div>
|
||||
<div class="settings-shortcuts">
|
||||
<button type="button" data-settings-action="model"><span>模型与推理强度</span><span id="settingsModelValue">默认</span></button>
|
||||
<button type="button" data-settings-action="permission"><span>修改权限</span><span id="settingsPermissionValue">工作区写入</span></button>
|
||||
<label id="localeSetting" class="settings-locale">
|
||||
<span>语言</span>
|
||||
<select id="localeSelect" aria-label="语言">
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-divider"></div>
|
||||
<div class="popover-subtitle">当前会话</div>
|
||||
<dl>
|
||||
<dt>工作区</dt><dd id="popoverCwd">-</dd>
|
||||
<dt>模式</dt><dd id="popoverMode">本地模式</dd>
|
||||
<dt>thread</dt><dd id="popoverThread">-</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div id="sessionPicker" class="panel-popover session-picker" hidden role="dialog" aria-label="最近会话">
|
||||
<div class="session-picker-header">
|
||||
<span class="popover-title">最近会话</span>
|
||||
<button id="sessionPickerRefresh" class="session-picker-refresh" type="button" title="刷新会话列表" aria-label="刷新会话列表">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 5V2m0 0h-3m3 0-2.1 2.1A5 5 0 1 0 13 9" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-search">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.8" cy="6.8" r="3.8" /><path d="m9.7 9.7 3.2 3.2" /></svg>
|
||||
<label class="sr-only" for="sessionSearchInput">搜索最近会话</label>
|
||||
<input id="sessionSearchInput" type="search" autocomplete="off" spellcheck="false" placeholder="搜索最近会话" aria-label="搜索最近会话" aria-controls="sessionList" aria-expanded="false" />
|
||||
<button id="sessionSearchClear" class="session-search-clear" type="button" title="清除搜索" aria-label="清除搜索" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sessionPickerStatus" class="session-picker-status" role="status" aria-live="polite"></div>
|
||||
<div id="sessionList" class="session-list" role="listbox" aria-label="可用会话" tabindex="0"></div>
|
||||
</div>
|
||||
|
||||
<section class="chat-panel" aria-label="对话内容">
|
||||
<div id="output" class="output chat-scroll" tabindex="0" aria-live="polite" aria-label="Codex 消息"></div>
|
||||
<button id="scrollToBottom" class="scroll-to-bottom" type="button" aria-label="回到最新消息" aria-hidden="true" tabindex="-1">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v9M4.5 8.5 8 12l3.5-3.5" /></svg>
|
||||
<span class="scroll-working-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
<div id="inlineRequests" class="inline-requests" aria-live="polite" aria-label="待处理的 Codex 请求"></div>
|
||||
</section>
|
||||
|
||||
<section id="messageForm" class="composer" aria-label="发送消息">
|
||||
<section id="subagentsPanel" class="subagents-panel" aria-label="子代理" hidden>
|
||||
<button id="subagentsToggle" class="subagents-toggle" type="button" aria-expanded="false">
|
||||
<span class="subagents-title">子代理</span>
|
||||
<span id="subagentsCount" class="subagents-count"></span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
|
||||
</button>
|
||||
<div id="subagentsList" class="subagents-list"></div>
|
||||
</section>
|
||||
<div id="liveActivity" class="live-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="activity-spinner" aria-hidden="true"></span>
|
||||
<span class="activity-label"></span>
|
||||
<span class="activity-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="activity-elapsed"></span>
|
||||
</div>
|
||||
<div class="composer-surface">
|
||||
<div
|
||||
id="messageInput"
|
||||
class="composer-editor"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder="提交后续变更要求"
|
||||
spellcheck="true"
|
||||
></div>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-hint">
|
||||
<button id="composerPlusButton" class="composer-icon-button" type="button" aria-haspopup="menu" aria-expanded="false" title="添加文件及更多内容" aria-label="添加文件及更多内容">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
|
||||
</button>
|
||||
<div id="composerPlusMenu" class="composer-popover composer-plus-menu" role="menu" hidden>
|
||||
<div class="composer-popover-heading">添加文件及更多内容</div>
|
||||
<button type="button" role="menuitem" data-composer-action="attach">添加文件</button>
|
||||
<button type="button" role="menuitem" data-composer-action="photo">添加照片</button>
|
||||
<button type="button" role="menuitem" data-composer-action="workspace">添加工作区上下文</button>
|
||||
<button type="button" role="menuitem" data-composer-action="web-search">网页搜索</button>
|
||||
</div>
|
||||
<input id="attachmentInput" type="file" accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h,image/*" multiple hidden />
|
||||
<button id="permissionChip" class="permission-chip" type="button" aria-haspopup="menu" aria-expanded="false" title="修改权限" aria-label="修改权限">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 13 4v3.6c0 3-2 5.6-5 6.6-3-1-5-3.6-5-6.6V4l5-2.2Z" /><path d="m5.5 8 1.6 1.6L10.8 6" /></svg>
|
||||
<span id="permissionLabel">工作区写入</span>
|
||||
<svg class="permission-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
|
||||
</button>
|
||||
<div id="permissionMenu" class="composer-popover permission-menu" role="menu" aria-label="权限设置" hidden>
|
||||
<div class="composer-popover-heading">修改权限</div>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="ask" aria-checked="false"><span>需要时询问</span><small>编辑外部文件和联网时始终询问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="auto" aria-checked="false"><span>由 Codex 审批</span><small>仅对可能不安全的操作询问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="full" aria-checked="false"><span>完全访问</span><small>不限制联网或文件访问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="custom" aria-checked="false"><span>自定义</span><small>使用 config.toml 中的权限</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="readonly" aria-checked="false"><span>只读</span><small>仅查看文件,不修改工作区</small></button>
|
||||
</div>
|
||||
<div id="permissionConfirm" class="permission-confirm" role="dialog" aria-modal="true" aria-labelledby="permissionConfirmTitle" hidden>
|
||||
<div id="permissionConfirmTitle" class="permission-confirm-title">确认完全访问</div>
|
||||
<p>完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。</p>
|
||||
<div class="permission-confirm-actions">
|
||||
<button id="permissionConfirmCancel" type="button">取消</button>
|
||||
<button id="permissionConfirmAccept" class="primary" type="button">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="usagePicker" class="usage-picker" hidden>
|
||||
<button id="usageButton" class="usage-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="查看上下文用量" aria-label="查看上下文用量"><span id="usageRing" class="usage-ring" aria-hidden="true"><span id="usageLabel">0%</span></span></button>
|
||||
<div id="usageMenu" class="composer-popover usage-menu" role="dialog" aria-label="上下文用量" hidden>
|
||||
<div class="composer-popover-heading">上下文用量</div>
|
||||
<div id="usageSummary" class="usage-summary">暂无用量数据</div>
|
||||
<div class="usage-meter"><span id="usageMeterBar"></span></div>
|
||||
<div id="usageDetails" class="usage-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="factApp" class="sr-only">-</span>
|
||||
<span id="factClients" class="sr-only">-</span>
|
||||
<span id="factRequests" class="sr-only">0</span>
|
||||
</div>
|
||||
<div class="composer-actions">
|
||||
<div id="modelPicker" class="model-picker">
|
||||
<button id="modelPickerButton" class="model-picker-button" type="button" aria-haspopup="menu" aria-expanded="false" title="切换模型与推理强度" hidden>
|
||||
<span id="modelLabel" class="model-label"></span>
|
||||
<span id="modelEffortLabel" class="model-effort-label"></span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
|
||||
</button>
|
||||
<div id="modelMenu" class="model-menu" role="menu" aria-label="模型与推理强度" hidden>
|
||||
<div id="modelPowerView" class="model-power-view">
|
||||
<div class="model-power-heading">
|
||||
<span>推理强度</span>
|
||||
<button id="modelAdvancedToggle" class="model-advanced-toggle" type="button">高级</button>
|
||||
</div>
|
||||
<div class="model-power-control">
|
||||
<span class="model-power-label">更高效</span>
|
||||
<input id="modelPowerSlider" class="model-power-slider" type="range" min="0" max="3" step="1" value="1" aria-label="强度" aria-describedby="modelPowerInstructions" />
|
||||
<span class="model-power-label">更智能</span>
|
||||
</div>
|
||||
<div id="modelPowerValue" class="model-power-value"></div>
|
||||
<span id="modelPowerInstructions" class="sr-only">使用左右方向键调整强度</span>
|
||||
</div>
|
||||
<div id="modelAdvancedView" class="model-advanced-view" hidden>
|
||||
<div class="model-advanced-toolbar">
|
||||
<button id="modelAdvancedBack" class="model-advanced-back" type="button" aria-label="返回模型强度">‹</button>
|
||||
<span>模型与推理强度</span>
|
||||
</div>
|
||||
<div class="model-menu-heading">模型</div>
|
||||
<div id="modelOptions" class="model-options" role="listbox" aria-label="模型"></div>
|
||||
<div class="model-menu-heading effort-heading">推理强度</div>
|
||||
<div id="effortOptions" class="effort-options" role="listbox" aria-label="推理强度"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="interruptButton" class="compact-action interrupt-action" type="button" disabled title="中断当前 turn" aria-label="中断当前 turn">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1" /></svg>
|
||||
</button>
|
||||
<button id="steerButton" class="primary compact-action steer-action" type="button" disabled title="发送后续指令" aria-label="发送后续指令">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
|
||||
</button>
|
||||
<button id="startTurnButton" class="primary send-button" type="button" disabled title="发送消息" aria-label="发送消息">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-row">
|
||||
<span class="connection-mode-label">
|
||||
<svg class="mode-icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8" rx="1" /><path d="M5 13h6M8 11v2" /></svg>
|
||||
<span id="modeLabel">本地模式</span>
|
||||
</span>
|
||||
<div id="controlModeSwitch" class="control-mode-switch" role="group" aria-label="控制模式" aria-busy="false" data-mode="sync" data-switching="false">
|
||||
<button type="button" data-control-mode="sync" aria-pressed="true" title="同步模式跟随 VS Code 当前会话" disabled>同步</button>
|
||||
<button type="button" data-control-mode="async" aria-pressed="false" title="异步模式可独立管理会话" disabled>异步</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<button id="restorePanel" class="restore-panel" type="button" hidden>显示 Codex</button>
|
||||
|
||||
<!-- Protocol compatibility state stays out of the visual shell. -->
|
||||
<section class="compatibility-state" aria-hidden="true" hidden inert>
|
||||
<details id="sessionSettings">
|
||||
<summary>会话设置</summary>
|
||||
<div class="settings-grid">
|
||||
<label>工作目录<input id="cwdInput" type="text" /></label>
|
||||
<label>模型<input id="modelInput" type="text" placeholder="留空使用默认模型" /></label>
|
||||
<label>沙箱
|
||||
<select id="sandboxInput">
|
||||
<option value="workspace-write">workspace-write</option>
|
||||
<option value="read-only">read-only</option>
|
||||
<option value="danger-full-access">danger-full-access</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>审批策略
|
||||
<select id="approvalInput">
|
||||
<option value="on-request">on-request</option>
|
||||
<option value="untrusted">untrusted</option>
|
||||
<option value="never">never</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="startThreadButton" class="secondary" type="button">启动新 thread</button>
|
||||
<div class="ids">
|
||||
<span>thread</span><code id="threadId">-</code>
|
||||
<span>turn</span><code id="turnId">-</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="connectionSettings">
|
||||
<summary>连接设置</summary>
|
||||
<label class="token-field">
|
||||
<span id="tokenLabel">本机连接(无需 token)</span>
|
||||
<input id="tokenInput" type="password" autocomplete="off" placeholder="本机模式无需填写;认证模式再填写" />
|
||||
</label>
|
||||
</details>
|
||||
<span id="sessionMode">已附着当前会话</span>
|
||||
<span id="latestSeq">seq -</span>
|
||||
<span id="outputHint">等待连接</span>
|
||||
<button id="clearOutputButton" type="button">清空对话</button>
|
||||
<span id="lastEvent">-</span>
|
||||
<details id="requestsPanel"><summary><span>授权与输入</span><span id="requestCount" class="badge warning">0</span></summary><div id="requests" class="requests empty">暂无待处理请求</div></details>
|
||||
</section>
|
||||
|
||||
<template id="requestTemplate">
|
||||
<article class="request">
|
||||
<div class="request-title"><span class="request-icon" aria-hidden="true">!</span><strong class="request-method"></strong><span class="request-risk"></span><span class="request-id"></span></div>
|
||||
<p class="request-summary"></p>
|
||||
<pre class="request-command"></pre>
|
||||
<div class="request-questions"></div>
|
||||
<label class="request-scope-wrap" hidden>
|
||||
<span>授权范围</span>
|
||||
<select class="request-scope">
|
||||
<option value="turn">仅本次 turn</option>
|
||||
<option value="session">当前会话</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="request-details">
|
||||
<summary>查看请求数据</summary>
|
||||
<pre class="request-json"></pre>
|
||||
</details>
|
||||
<textarea class="request-response" rows="4" aria-label="JSON 响应"></textarea>
|
||||
<div class="button-row request-actions">
|
||||
<button class="primary request-allow">允许</button>
|
||||
<button class="secondary request-deny">拒绝</button>
|
||||
<button class="secondary request-send">发送 JSON</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<script src="./embed-bridge.js" defer></script>
|
||||
<script src="./i18n.js" defer></script>
|
||||
<script src="./app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,745 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js");
|
||||
const { RelayHost } = require("../vscode-extension/dist/relayHost.js");
|
||||
|
||||
class FakeRpc {
|
||||
responses = [];
|
||||
requests = [];
|
||||
notificationListener;
|
||||
requestListener;
|
||||
exitListener;
|
||||
overrides;
|
||||
|
||||
constructor(overrides = {}) {
|
||||
this.overrides = overrides;
|
||||
}
|
||||
|
||||
get running() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async start() {}
|
||||
|
||||
async request(method, params) {
|
||||
this.requests.push({ method, params });
|
||||
if (Object.prototype.hasOwnProperty.call(this.overrides, method)) {
|
||||
const override = this.overrides[method];
|
||||
return typeof override === "function" ? override(params) : override;
|
||||
}
|
||||
if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" };
|
||||
if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" };
|
||||
if (method === "turn/start") return { turn: { id: "turn-test" } };
|
||||
if (method === "turn/steer") return { turn: { id: "turn-test" } };
|
||||
if (method === "turn/interrupt") return {};
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
}
|
||||
|
||||
notify() {}
|
||||
|
||||
respond(id, result) {
|
||||
this.responses.push({ id, result });
|
||||
}
|
||||
|
||||
respondError(id, code, message) {
|
||||
this.responses.push({ id, error: { code, message } });
|
||||
}
|
||||
|
||||
onNotification(listener) {
|
||||
this.notificationListener = listener;
|
||||
return { dispose: () => undefined };
|
||||
}
|
||||
|
||||
onServerRequest(listener) {
|
||||
this.requestListener = listener;
|
||||
return { dispose: () => undefined };
|
||||
}
|
||||
|
||||
onExit(listener) {
|
||||
this.exitListener = listener;
|
||||
return { dispose: () => undefined };
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
emitRequest(request) {
|
||||
this.requestListener(request);
|
||||
}
|
||||
|
||||
emitNotification(notification) {
|
||||
this.notificationListener(notification);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRelay {
|
||||
frames = [];
|
||||
listeners = new Set();
|
||||
|
||||
async connect() {}
|
||||
|
||||
send(frame) {
|
||||
this.frames.push(frame);
|
||||
}
|
||||
|
||||
onMessage(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
|
||||
rpc.emitRequest({
|
||||
id: 1,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" },
|
||||
});
|
||||
rpc.emitRequest({
|
||||
id: "1",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" },
|
||||
});
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]);
|
||||
await adapter.respondApproval(1, "deny");
|
||||
await adapter.respondApproval("1", "deny");
|
||||
assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]);
|
||||
assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("commandActions are included in high-risk approval classification", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
rpc.emitRequest({
|
||||
id: 2,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "t",
|
||||
turnId: "u",
|
||||
itemId: "actions",
|
||||
command: null,
|
||||
commandActions: [{ type: "unknown", command: "sudo rm -rf /" }],
|
||||
},
|
||||
});
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.pendingApprovals[0].risk, "high");
|
||||
await adapter.respondApproval(2, "deny");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("output snapshots stay redacted and interrupt clears the active turn", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
await adapter.startThread({});
|
||||
await adapter.startTurn({ text: "hello" });
|
||||
assert.equal((await adapter.snapshot()).turnId, "turn-test");
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { delta: "credential Bearer abcdefghijklmnop" },
|
||||
});
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false);
|
||||
assert.match(snapshot.outputTail, /\[REDACTED\]/);
|
||||
|
||||
await adapter.interruptTurn({});
|
||||
const afterInterrupt = await adapter.snapshot();
|
||||
assert.equal(afterInterrupt.turnId, null);
|
||||
assert.equal(afterInterrupt.state, "idle");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter lists app-server threads and exposes the model catalog", async () => {
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": {
|
||||
data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }],
|
||||
nextCursor: null,
|
||||
},
|
||||
"thread/list": {
|
||||
data: [
|
||||
{
|
||||
id: "thread-recent",
|
||||
name: null,
|
||||
preview: "Inspect the workspace\nwith detail",
|
||||
cwd: "/tmp/workspace",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_100,
|
||||
status: { type: "idle" },
|
||||
source: "vscode",
|
||||
},
|
||||
],
|
||||
nextCursor: "next-page",
|
||||
backwardsCursor: null,
|
||||
},
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" });
|
||||
assert.equal(result.sessions[0].threadId, "thread-recent");
|
||||
assert.equal(result.sessions[0].title, "Inspect the workspace with detail");
|
||||
assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000);
|
||||
assert.equal(result.nextCursor, "next-page");
|
||||
const listRequest = rpc.requests.find((entry) => entry.method === "thread/list");
|
||||
assert.deepEqual(listRequest.params, {
|
||||
limit: 100,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
searchTerm: "workspace",
|
||||
});
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.mode, "async");
|
||||
assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter projects live token usage notifications into metadata and snapshots", async () => {
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(event));
|
||||
await adapter.start();
|
||||
await adapter.startThread({});
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-test",
|
||||
// A usage update may arrive after the turn has completed. It must not
|
||||
// make the adapter report that historical turn as active again.
|
||||
turnId: "turn-finished",
|
||||
tokenUsage: {
|
||||
total: {
|
||||
totalTokens: 1_200,
|
||||
inputTokens: 800,
|
||||
cachedInputTokens: 100,
|
||||
cacheWriteInputTokens: 20,
|
||||
outputTokens: 300,
|
||||
reasoningOutputTokens: 80,
|
||||
},
|
||||
last: {
|
||||
totalTokens: 450,
|
||||
inputTokens: 300,
|
||||
cachedInputTokens: 40,
|
||||
cacheWriteInputTokens: 10,
|
||||
outputTokens: 100,
|
||||
reasoningOutputTokens: 40,
|
||||
},
|
||||
modelContextWindow: 128_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const expected = {
|
||||
total: {
|
||||
totalTokens: 1_200,
|
||||
inputTokens: 800,
|
||||
cachedInputTokens: 100,
|
||||
cacheWriteInputTokens: 20,
|
||||
outputTokens: 300,
|
||||
reasoningOutputTokens: 80,
|
||||
},
|
||||
last: {
|
||||
totalTokens: 450,
|
||||
inputTokens: 300,
|
||||
cachedInputTokens: 40,
|
||||
cacheWriteInputTokens: 10,
|
||||
outputTokens: 100,
|
||||
reasoningOutputTokens: 40,
|
||||
},
|
||||
modelContextWindow: 128_000,
|
||||
};
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.deepEqual(snapshot.metadata.tokenUsage, expected);
|
||||
assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected);
|
||||
assert.equal(snapshot.turnId, null);
|
||||
|
||||
const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated");
|
||||
assert.ok(usageEvent);
|
||||
assert.deepEqual(usageEvent.payload.tokenUsage, expected);
|
||||
assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected);
|
||||
// Keep the raw diagnostic envelope redacted while exposing only the safe
|
||||
// numeric projection to the browser.
|
||||
assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]");
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-test",
|
||||
turnId: "turn-finished",
|
||||
tokenUsage: { total: { inputTokens: -1 } },
|
||||
},
|
||||
});
|
||||
assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter resumes a thread with structured history and ignores late notifications", async () => {
|
||||
const thread = {
|
||||
id: "thread-selected",
|
||||
name: "Selected thread",
|
||||
preview: "hello",
|
||||
cwd: "/tmp/selected",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_010,
|
||||
status: { type: "idle" },
|
||||
turns: [{
|
||||
id: "turn-history",
|
||||
status: "completed",
|
||||
startedAt: 1_700_000_001,
|
||||
completedAt: 1_700_000_004,
|
||||
durationMs: 3_000,
|
||||
items: [
|
||||
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
|
||||
{ type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] },
|
||||
{ type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] },
|
||||
{ type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null },
|
||||
"thread/resume": {
|
||||
thread,
|
||||
model: "gpt-5.6-sol",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/tmp/selected",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "workspaceWrite" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(event));
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.selectSession({ threadId: "thread-selected" });
|
||||
assert.equal(result.threadId, "thread-selected");
|
||||
let snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.messages.length, 4);
|
||||
assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]);
|
||||
assert.equal(snapshot.messages[2].output, "/tmp/selected\n");
|
||||
assert.equal(snapshot.metadata.title, "Selected thread");
|
||||
assert.equal(snapshot.metadata.threadSettings.effort, "high");
|
||||
assert.equal(snapshot.metadata.historyComplete, true);
|
||||
assert.equal(snapshot.status.turnStatus, "completed");
|
||||
assert.match(snapshot.outputTail, /Done/);
|
||||
assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true));
|
||||
const authoritative = events.find((event) => event.type === "session.snapshot");
|
||||
assert.equal(authoritative.payload.threadId, "thread-selected");
|
||||
assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol");
|
||||
assert.equal(authoritative.payload.messages.length, 4);
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-old",
|
||||
turnId: "turn-old",
|
||||
completedAtMs: Date.now(),
|
||||
item: { type: "agentMessage", id: "late-old", text: "wrong thread" },
|
||||
},
|
||||
});
|
||||
rpc.emitNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-selected",
|
||||
turnId: "turn-live",
|
||||
completedAtMs: Date.now(),
|
||||
item: { type: "agentMessage", id: "current-item", text: "current thread" },
|
||||
},
|
||||
});
|
||||
snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false);
|
||||
assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter hydrates paginated turns and items into chronological complete history", async () => {
|
||||
const threadId = "thread-paged-history";
|
||||
const userItem = (id, text) => ({
|
||||
type: "userMessage",
|
||||
id,
|
||||
clientId: null,
|
||||
content: [{ type: "text", text, text_elements: [] }],
|
||||
});
|
||||
const assistantItem = (id, text) => ({
|
||||
type: "agentMessage",
|
||||
id,
|
||||
text,
|
||||
phase: "final_answer",
|
||||
});
|
||||
const earlyUser = userItem("early-user", "first question");
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
"thread/resume": {
|
||||
thread: {
|
||||
id: threadId,
|
||||
name: "Paged history",
|
||||
preview: "first question",
|
||||
cwd: "/tmp/paged",
|
||||
createdAt: 50,
|
||||
updatedAt: 350,
|
||||
historyMode: "paginated",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.6-sol",
|
||||
cwd: "/tmp/paged",
|
||||
initialTurnsPage: {
|
||||
data: [{
|
||||
id: "turn-late",
|
||||
status: "completed",
|
||||
startedAt: 300,
|
||||
completedAt: 310,
|
||||
itemsView: "full",
|
||||
items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")],
|
||||
}],
|
||||
nextCursor: "turn-page-2",
|
||||
backwardsCursor: null,
|
||||
},
|
||||
},
|
||||
"thread/turns/list": (params) => {
|
||||
if (params.cursor === "turn-page-2") {
|
||||
return {
|
||||
data: [{
|
||||
id: "turn-early",
|
||||
status: "completed",
|
||||
startedAt: 100,
|
||||
completedAt: 110,
|
||||
itemsView: "summary",
|
||||
items: [earlyUser],
|
||||
}],
|
||||
nextCursor: "turn-page-3",
|
||||
backwardsCursor: null,
|
||||
};
|
||||
}
|
||||
assert.equal(params.cursor, "turn-page-3");
|
||||
return {
|
||||
data: [{
|
||||
id: "turn-middle",
|
||||
status: "completed",
|
||||
startedAt: 200,
|
||||
completedAt: 210,
|
||||
itemsView: "full",
|
||||
items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")],
|
||||
}],
|
||||
nextCursor: null,
|
||||
backwardsCursor: null,
|
||||
};
|
||||
},
|
||||
"thread/items/list": (params) => {
|
||||
assert.equal(params.turnId, "turn-early");
|
||||
return {
|
||||
data: [
|
||||
// The summary row is repeated by the full item page; hydration must
|
||||
// de-duplicate it while adding the omitted assistant response.
|
||||
{ turnId: "turn-early", item: earlyUser },
|
||||
{ turnId: "turn-early", item: assistantItem("early-agent", "first answer") },
|
||||
],
|
||||
nextCursor: null,
|
||||
backwardsCursor: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const events = [];
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
adapter.onEvent((event) => events.push(event));
|
||||
await adapter.start();
|
||||
await adapter.selectSession({ threadId });
|
||||
|
||||
const resume = rpc.requests.find((entry) => entry.method === "thread/resume");
|
||||
assert.deepEqual(resume.params, {
|
||||
threadId,
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" },
|
||||
});
|
||||
const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list");
|
||||
assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]);
|
||||
assert.ok(turnPages.every((entry) => entry.params.threadId === threadId
|
||||
&& entry.params.limit === 100
|
||||
&& entry.params.sortDirection === "asc"
|
||||
&& entry.params.itemsView === "full"));
|
||||
const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list");
|
||||
assert.deepEqual(itemPages.map((entry) => entry.params), [{
|
||||
threadId,
|
||||
turnId: "turn-early",
|
||||
limit: 100,
|
||||
sortDirection: "asc",
|
||||
}]);
|
||||
assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false);
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [
|
||||
["turn-early", "first question"],
|
||||
["turn-early", "first answer"],
|
||||
["turn-middle", "second question"],
|
||||
["turn-middle", "second answer"],
|
||||
["turn-late", "third question"],
|
||||
["turn-late", "third answer"],
|
||||
]);
|
||||
assert.equal(snapshot.metadata.historyComplete, true);
|
||||
const outputSnapshot = events.find((event) => event.type === "output.snapshot");
|
||||
assert.equal(outputSnapshot.payload.historyComplete, true);
|
||||
assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [
|
||||
"first question",
|
||||
"first answer",
|
||||
"second question",
|
||||
"second answer",
|
||||
"third question",
|
||||
"third answer",
|
||||
]);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter falls back to thread/read when resume omits existing history", async () => {
|
||||
const metadataThread = {
|
||||
id: "thread-paginated",
|
||||
preview: "existing conversation",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_100,
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
};
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
"thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" },
|
||||
"thread/read": {
|
||||
thread: {
|
||||
...metadataThread,
|
||||
turns: [{
|
||||
id: "turn-read",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
await adapter.selectSession({ threadId: "thread-paginated" });
|
||||
const read = rpc.requests.find((entry) => entry.method === "thread/read");
|
||||
assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true });
|
||||
assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter starts new sessions and sends flat durable thread settings", async () => {
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
"thread/start": {
|
||||
thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] },
|
||||
model: "gpt-5.6-sol",
|
||||
cwd: "/tmp/new",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
"thread/settings/update": { ok: true },
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc);
|
||||
await adapter.start();
|
||||
await adapter.newSession({});
|
||||
await adapter.updateThreadSettings({
|
||||
threadSettings: {
|
||||
model: "gpt-5.6-terra",
|
||||
effort: "high",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandboxPolicy: "workspace-write",
|
||||
permissions: ":workspace",
|
||||
},
|
||||
});
|
||||
const start = rpc.requests.find((entry) => entry.method === "thread/start");
|
||||
assert.equal(start.params.cwd, "/tmp/default");
|
||||
const update = rpc.requests.find((entry) => entry.method === "thread/settings/update");
|
||||
assert.deepEqual(update.params, {
|
||||
threadId: "thread-new",
|
||||
model: "gpt-5.6-terra",
|
||||
effort: "high",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
permissions: ":workspace",
|
||||
});
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false);
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.model, "gpt-5.6-terra");
|
||||
assert.equal(snapshot.metadata.latestReasoningEffort, "high");
|
||||
assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("thread settings updates require the send_task_input capability", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
const relay = new FakeRelay();
|
||||
const host = new RelayHost({
|
||||
adapter,
|
||||
relay,
|
||||
capabilities: ["read_output"],
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
await host.handleFrame({
|
||||
kind: "command",
|
||||
type: "thread.settings.update",
|
||||
commandId: "settings-without-capability",
|
||||
actor: { role: "operator" },
|
||||
payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } },
|
||||
});
|
||||
|
||||
const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability");
|
||||
assert.equal(result.type, "command.rejected");
|
||||
assert.match(result.payload.error, /missing capability: send_task_input/);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("RelayHost exposes session list as read-only and protects session selection", async () => {
|
||||
const relay = new FakeRelay();
|
||||
const calls = [];
|
||||
const adapter = {
|
||||
async start() {},
|
||||
async sendInput() { return {}; },
|
||||
async cancel() { return {}; },
|
||||
async respondApproval() { return {}; },
|
||||
async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; },
|
||||
onEvent() { return { dispose() {} }; },
|
||||
async dispose() {},
|
||||
async listSessions(params) {
|
||||
calls.push({ method: "listSessions", params });
|
||||
return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" };
|
||||
},
|
||||
async selectSession(params) {
|
||||
calls.push({ method: "selectSession", params });
|
||||
return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true };
|
||||
},
|
||||
async newSession(params) {
|
||||
calls.push({ method: "newSession", params });
|
||||
return { opened: true, command: "chatgpt.newCodexPanel" };
|
||||
},
|
||||
getControlMode() { return "sync"; },
|
||||
async setControlMode(params) {
|
||||
calls.push({ method: "setControlMode", params });
|
||||
return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 };
|
||||
},
|
||||
};
|
||||
const host = new RelayHost({
|
||||
adapter,
|
||||
relay,
|
||||
capabilities: ["read_output", "send_task_input"],
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} });
|
||||
const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1");
|
||||
assert.equal(listed.type, "command.accepted");
|
||||
assert.equal(listed.payload.result.activeThreadId, "thread-a");
|
||||
assert.equal(calls[0].method, "listSessions");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } });
|
||||
const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer");
|
||||
assert.equal(denied.type, "command.rejected");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } });
|
||||
const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator");
|
||||
assert.equal(selected.type, "command.accepted");
|
||||
assert.equal(selected.payload.result.threadId, "thread-b");
|
||||
assert.equal(calls.at(-1).method, "selectSession");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} });
|
||||
const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer");
|
||||
assert.equal(deniedNew.type, "command.rejected");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} });
|
||||
const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator");
|
||||
assert.equal(opened.type, "command.accepted");
|
||||
assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel");
|
||||
assert.equal(calls.at(-1).method, "newSession");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} });
|
||||
const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer");
|
||||
assert.equal(mode.type, "command.accepted");
|
||||
assert.equal(mode.payload.result.mode, "sync");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } });
|
||||
const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer");
|
||||
assert.equal(deniedMode.type, "command.rejected");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } });
|
||||
const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator");
|
||||
assert.equal(changedMode.type, "command.accepted");
|
||||
assert.equal(changedMode.payload.result.controlMode, "async");
|
||||
assert.equal(calls.at(-1).method, "setControlMode");
|
||||
});
|
||||
|
||||
test("approval decision conflicts and unknown tagged objects fail closed", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
const relay = new FakeRelay();
|
||||
const host = new RelayHost({
|
||||
adapter,
|
||||
relay,
|
||||
capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"],
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
rpc.emitRequest({
|
||||
id: 3,
|
||||
method: "execCommandApproval",
|
||||
params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] },
|
||||
});
|
||||
await host.handleFrame({
|
||||
kind: "command",
|
||||
type: "approval.respond",
|
||||
commandId: "conflicting-response",
|
||||
actor: { role: "operator" },
|
||||
payload: {
|
||||
requestId: 3,
|
||||
decision: "deny",
|
||||
response: { decision: "approved_mcp_policy_amendment" },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(rpc.responses[0], {
|
||||
id: 3,
|
||||
result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } },
|
||||
});
|
||||
|
||||
rpc.emitRequest({
|
||||
id: 4,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" },
|
||||
});
|
||||
await host.handleFrame({
|
||||
kind: "command",
|
||||
type: "approval.respond",
|
||||
commandId: "unknown-tagged-response",
|
||||
actor: { role: "operator" },
|
||||
payload: {
|
||||
requestId: 4,
|
||||
decision: "allow",
|
||||
response: { decision: { futurePolicyGrant: { scope: "all" } } },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(rpc.responses[1], {
|
||||
id: 4,
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
await adapter.dispose();
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { WebSocket } = require("ws");
|
||||
|
||||
const { AetherVscodexCloudServer, RoomManager } = require("../cloud/server.js");
|
||||
|
||||
const internalToken = "test-internal-token-with-enough-entropy";
|
||||
|
||||
function internalFetch(base, pathname, options = {}) {
|
||||
return fetch(`${base}${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function websocketClient(base, clientType, token, sessionId) {
|
||||
const socket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
|
||||
const messages = [];
|
||||
const waiters = [];
|
||||
const wait = (predicate, timeout = 5_000, label = "websocket frame") => new Promise((resolve, reject) => {
|
||||
const existing = messages.find(predicate);
|
||||
if (existing) return resolve(existing);
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.findIndex((entry) => entry.resolve === resolve);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
reject(new Error(`timed out waiting for ${label}; received: ${JSON.stringify(messages.map((message) => ({ type: message.type, kind: message.kind, commandId: message.commandId })))}`));
|
||||
}, timeout);
|
||||
waiters.push({
|
||||
predicate,
|
||||
resolve: (message) => {
|
||||
clearTimeout(timer);
|
||||
resolve(message);
|
||||
},
|
||||
});
|
||||
});
|
||||
socket.on("message", (data) => {
|
||||
const message = JSON.parse(data.toString("utf8"));
|
||||
messages.push(message);
|
||||
for (let index = waiters.length - 1; index >= 0; index -= 1) {
|
||||
if (!waiters[index].predicate(message)) continue;
|
||||
const waiter = waiters.splice(index, 1)[0];
|
||||
waiter.resolve(message);
|
||||
}
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once("open", () => {
|
||||
socket.send(JSON.stringify({ v: 1, kind: "hello", clientType, protocol: 1, ...(sessionId ? { sessionId } : {}) }));
|
||||
socket.send(JSON.stringify(clientType === "host"
|
||||
? { v: 1, kind: "auth", accessToken: token }
|
||||
: { type: "auth", token }));
|
||||
wait((message) => message.type === "auth.ok").then(() => resolve({ socket, wait, messages }), reject);
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function pairDevice(base, userId, name) {
|
||||
const pairingResponse = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/pairings`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
assert.equal(pairingResponse.status, 201);
|
||||
const pairing = await pairingResponse.json();
|
||||
const exchangeResponse = await fetch(`${base}/v1/pairings/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: pairing.code, name }),
|
||||
});
|
||||
assert.equal(exchangeResponse.status, 201);
|
||||
return exchangeResponse.json();
|
||||
}
|
||||
|
||||
async function browserTicket(base, userId, deviceId) {
|
||||
const response = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/ws-tickets`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function exchangeAttempt(base, headers = {}) {
|
||||
return fetch(`${base}/v1/pairings/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify({ code: "INVALID-CODE" }),
|
||||
});
|
||||
}
|
||||
|
||||
test("pairing exchange trusts a gateway client IP only with valid internal authentication", async (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-rate-limit-"));
|
||||
const server = new AetherVscodexCloudServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
internalToken,
|
||||
publicWsUrl: "wss://aether.example/api/vscodex/ws",
|
||||
dataDir,
|
||||
});
|
||||
await server.start();
|
||||
t.after(async () => {
|
||||
await server.stop();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const trustedHeaders = (clientIp) => ({
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
"X-Aether-Client-IP": clientIp,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 400);
|
||||
}
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 429);
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.11"))).status, 400);
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("2001:db8::10"))).status, 400);
|
||||
|
||||
server.exchangeAttempts.clear();
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": `198.51.100.${20 + attempt}` })).status, 400);
|
||||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, {
|
||||
Authorization: "Bearer invalid-internal-token",
|
||||
"X-Aether-Client-IP": `198.51.100.${30 + attempt}`,
|
||||
})).status, 400);
|
||||
}
|
||||
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": "198.51.100.99" })).status, 429);
|
||||
|
||||
server.exchangeAttempts.clear();
|
||||
const invalidForwardedAddresses = ["proxy.internal", "198.51.100.40, 198.51.100.41"];
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders(invalidForwardedAddresses[attempt % 2]))).status, 400);
|
||||
}
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.42, 198.51.100.43"))).status, 429);
|
||||
});
|
||||
|
||||
test("cloud sidecar pairs a device and isolates host/browser traffic by Aether user and device", async (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-test-"));
|
||||
const server = new AetherVscodexCloudServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
internalToken,
|
||||
publicWsUrl: "wss://aether.example/api/vscodex/ws",
|
||||
dataDir,
|
||||
pairingTtlMs: 5_000,
|
||||
ticketTtlMs: 5_000,
|
||||
});
|
||||
await server.start();
|
||||
t.after(async () => {
|
||||
await server.stop();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const unauthorized = await fetch(`${base}/internal/v1/users/user-a/devices`);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
|
||||
const paired = await pairDevice(base, "user-a", "MacBook VS Code");
|
||||
assert.match(paired.device_token, /^avx1\./);
|
||||
const devicesResponse = await internalFetch(base, "/internal/v1/users/user-a/devices");
|
||||
assert.equal(devicesResponse.status, 200);
|
||||
const devices = await devicesResponse.json();
|
||||
assert.deepEqual(devices.devices.map((device) => ({ id: device.id, name: device.name, connected: device.connected })), [
|
||||
{ id: paired.device_id, name: "MacBook VS Code", connected: false },
|
||||
]);
|
||||
|
||||
const ticket = await browserTicket(base, "user-a", paired.device_id);
|
||||
assert.equal(ticket.ws_url, "/api/vscodex/ws");
|
||||
const host = await websocketClient(base, "host", paired.device_token, "host-user-a");
|
||||
const browser = await websocketClient(base, "web", ticket.ticket);
|
||||
t.after(() => host.socket.close());
|
||||
t.after(() => browser.socket.close());
|
||||
browser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: "connection.opened",
|
||||
id: "connection-a",
|
||||
sessionId: "host-user-a",
|
||||
seq: 1,
|
||||
ts: new Date().toISOString(),
|
||||
payload: {},
|
||||
}));
|
||||
host.socket.send(JSON.stringify({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: "session.snapshot",
|
||||
id: "snapshot-a",
|
||||
sessionId: "host-user-a",
|
||||
seq: 2,
|
||||
ts: new Date().toISOString(),
|
||||
payload: { threadId: "thread-a", state: "idle", messages: [{ kind: "assistant", text: "user-a-only" }] },
|
||||
}));
|
||||
const snapshot = await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot", 5_000, "session snapshot");
|
||||
assert.equal(snapshot.payload.threadId, "thread-a");
|
||||
assert.equal(snapshot.payload.messages[0].text, "user-a-only");
|
||||
|
||||
browser.socket.send(JSON.stringify({ type: "command", commandId: "cmd-a", method: "session/list", params: {} }));
|
||||
const command = await host.wait((message) => message.kind === "command" && message.commandId === "cmd-a", 5_000, "browser command");
|
||||
assert.equal(command.type, "session/list");
|
||||
|
||||
const secondUser = await pairDevice(base, "user-b", "Other VS Code");
|
||||
const secondTicket = await browserTicket(base, "user-b", secondUser.device_id);
|
||||
const secondBrowser = await websocketClient(base, "web", secondTicket.ticket);
|
||||
t.after(() => secondBrowser.socket.close());
|
||||
secondBrowser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(secondBrowser.messages.some((message) => message.payload?.threadId === "thread-a"), false);
|
||||
|
||||
const reusedTicket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
reusedTicket.once("open", () => {
|
||||
reusedTicket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
|
||||
reusedTicket.send(JSON.stringify({ type: "auth", token: ticket.ticket }));
|
||||
});
|
||||
reusedTicket.once("close", (code) => resolve(code));
|
||||
reusedTicket.once("error", reject);
|
||||
});
|
||||
assert.equal(await closed, 1008, "browser tickets are one-time credentials");
|
||||
});
|
||||
|
||||
test("device revocation closes its room and blocks future host authentication", async (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-revoke-"));
|
||||
const server = new AetherVscodexCloudServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
internalToken,
|
||||
publicWsUrl: "wss://aether.example/api/vscodex/ws",
|
||||
dataDir,
|
||||
});
|
||||
await server.start();
|
||||
t.after(async () => {
|
||||
await server.stop();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const paired = await pairDevice(base, "user-a", "Revoked device");
|
||||
const host = await websocketClient(base, "host", paired.device_token, "revoked-host");
|
||||
|
||||
const response = await internalFetch(base, `/internal/v1/users/user-a/devices/${paired.device_id}`, { method: "DELETE" });
|
||||
assert.equal(response.status, 204);
|
||||
await new Promise((resolve) => host.socket.once("close", resolve));
|
||||
|
||||
const rejected = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
rejected.once("open", () => {
|
||||
rejected.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: "retry" }));
|
||||
rejected.send(JSON.stringify({ v: 1, kind: "auth", accessToken: paired.device_token }));
|
||||
});
|
||||
rejected.once("close", (code) => resolve(code));
|
||||
rejected.once("error", reject);
|
||||
});
|
||||
assert.equal(await closed, 1008);
|
||||
});
|
||||
|
||||
test("room revocation wins a concurrent room creation", async () => {
|
||||
const rooms = new RoomManager();
|
||||
let releaseCreation;
|
||||
const creationGate = new Promise((resolve) => { releaseCreation = resolve; });
|
||||
let stopped = false;
|
||||
const room = {
|
||||
key: rooms.key("user-a", "device-a"),
|
||||
userId: "user-a",
|
||||
deviceId: "device-a",
|
||||
relay: { stop: async () => { stopped = true; } },
|
||||
connections: 0,
|
||||
lastActiveMs: Date.now(),
|
||||
};
|
||||
rooms.createRoom = async (key) => {
|
||||
await creationGate;
|
||||
rooms.rooms.set(key, room);
|
||||
return room;
|
||||
};
|
||||
|
||||
const pendingGet = rooms.get("user-a", "device-a");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const pendingRevoke = rooms.revoke("user-a", "device-a");
|
||||
releaseCreation();
|
||||
|
||||
await assert.rejects(pendingGet, /device revoked/);
|
||||
await pendingRevoke;
|
||||
assert.equal(stopped, true);
|
||||
assert.equal(rooms.rooms.has(room.key), false);
|
||||
await assert.rejects(rooms.get("user-a", "device-a"), /device revoked/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { mkdtempSync, rmSync } = require("node:fs");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
CODEX_IPC_METHOD_VERSIONS,
|
||||
CodexIpcClient,
|
||||
IpcFrameDecoder,
|
||||
applyIpcPatches,
|
||||
encodeIpcFrame,
|
||||
} = require("../vscode-extension/dist/codexIpc.js");
|
||||
|
||||
function waitFor(predicate, timeoutMs = 2_000) {
|
||||
const started = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = () => {
|
||||
if (predicate()) return resolve();
|
||||
if (Date.now() - started >= timeoutMs) return reject(new Error("timed out waiting for fixture"));
|
||||
setTimeout(poll, 5);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
test("private IPC framing handles split UTF-8 frames", () => {
|
||||
const message = {
|
||||
type: "broadcast",
|
||||
method: "thread-stream-following-changed",
|
||||
sourceClientId: "client-1",
|
||||
version: 1,
|
||||
params: { conversationId: "thread-1", hostId: "local", following: true, text: "中文" },
|
||||
};
|
||||
const frame = encodeIpcFrame(message);
|
||||
const decoder = new IpcFrameDecoder();
|
||||
const first = decoder.push(frame.subarray(0, 3));
|
||||
assert.deepEqual(first, []);
|
||||
const second = decoder.push(frame.subarray(3, frame.length - 1));
|
||||
assert.deepEqual(second, []);
|
||||
assert.deepEqual(decoder.push(frame.subarray(frame.length - 1)), [message]);
|
||||
});
|
||||
|
||||
test("applyIpcPatches updates a conversation snapshot", () => {
|
||||
const initial = { turns: [{ items: [{ text: "old" }] }], status: "idle" };
|
||||
const next = applyIpcPatches(initial, [
|
||||
{ op: "replace", path: ["turns", 0, "items", 0, "text"], value: "new" },
|
||||
{ op: "add", path: ["turns", 0, "items", 1], value: { text: "second" } },
|
||||
{ op: "replace", path: ["status"], value: "active" },
|
||||
]);
|
||||
assert.deepEqual(next, {
|
||||
turns: [{ items: [{ text: "new" }, { text: "second" }] }],
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
test("fixture owner receives follow/start/steer/interrupt/approval requests", async () => {
|
||||
const temp = mkdtempSync(path.join(os.tmpdir(), "codex-ipc-fixture-"));
|
||||
const socketPath = path.join(temp, "ipc.sock");
|
||||
const threadId = "11111111-1111-4111-8111-111111111111";
|
||||
const ownerId = "owner-client";
|
||||
const requests = [];
|
||||
const followingBroadcasts = [];
|
||||
let fixtureSocket;
|
||||
const server = net.createServer((socket) => {
|
||||
fixtureSocket = socket;
|
||||
const decoder = new IpcFrameDecoder();
|
||||
socket.on("data", (chunk) => {
|
||||
for (const message of decoder.push(chunk)) {
|
||||
if (message.type === "request" && message.method === "initialize") {
|
||||
socket.write(encodeIpcFrame({
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
resultType: "success",
|
||||
method: "initialize",
|
||||
handledByClientId: "fixture-client",
|
||||
result: { clientId: "fixture-client" },
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if (message.type === "broadcast" && message.method === "thread-stream-following-changed") {
|
||||
followingBroadcasts.push(message);
|
||||
const target = message.sourceClientId;
|
||||
socket.write(encodeIpcFrame({
|
||||
type: "broadcast",
|
||||
method: "thread-stream-state-changed",
|
||||
sourceClientId: ownerId,
|
||||
targetClientIds: [target],
|
||||
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"],
|
||||
params: {
|
||||
conversationId: threadId,
|
||||
hostId: "local",
|
||||
change: {
|
||||
type: "snapshot",
|
||||
revision: 1,
|
||||
conversationState: { id: threadId, title: "fixture", turns: [], requests: [] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if (message.type === "request") {
|
||||
requests.push(message);
|
||||
socket.write(encodeIpcFrame({
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
resultType: "success",
|
||||
method: message.method,
|
||||
handledByClientId: ownerId,
|
||||
result: { method: message.method, ok: true },
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, resolve);
|
||||
});
|
||||
const client = new CodexIpcClient({ socketPath, autoReconnect: false });
|
||||
const streamEvents = [];
|
||||
client.onStreamEvent((event) => streamEvents.push(event));
|
||||
await client.connect();
|
||||
await client.followConversation(threadId);
|
||||
await waitFor(() => streamEvents.some((event) => event.kind === "snapshot"));
|
||||
assert.equal(client.getConversationState(threadId).ownerClientId, ownerId);
|
||||
fixtureSocket.write(encodeIpcFrame({
|
||||
type: "broadcast",
|
||||
method: "thread-stream-following-status-requested",
|
||||
sourceClientId: ownerId,
|
||||
targetClientIds: ["fixture-client"],
|
||||
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"],
|
||||
params: { conversationId: threadId, hostId: "local" },
|
||||
}));
|
||||
await waitFor(() => followingBroadcasts.length >= 2);
|
||||
assert.deepEqual(followingBroadcasts[1].targetClientIds, [ownerId]);
|
||||
assert.deepEqual(followingBroadcasts[1].params, {
|
||||
conversationId: threadId,
|
||||
hostId: "local",
|
||||
following: true,
|
||||
});
|
||||
|
||||
await client.startTurn(threadId, "hello", { ownerClientId: ownerId });
|
||||
await client.steerTurn(threadId, "follow-up", { ownerClientId: ownerId });
|
||||
await client.updateThreadSettings(threadId, {
|
||||
model: "gpt-5.6-sol",
|
||||
effort: "ultra",
|
||||
multiAgentMode: "explicitRequestOnly",
|
||||
}, { ownerClientId: ownerId });
|
||||
await client.interruptTurn(threadId, { mode: "user-stop", expectedTurnId: "turn-1", ownerClientId: ownerId });
|
||||
await client.respondCommandApproval(threadId, 7, "decline", { ownerClientId: ownerId });
|
||||
await client.respondFileApproval(threadId, "8", "cancel", { ownerClientId: ownerId });
|
||||
await client.respondPermissionsApproval(threadId, 9, { permissions: {}, scope: "turn" }, { ownerClientId: ownerId });
|
||||
await client.respondUserInput(threadId, 10, { answers: {} }, { ownerClientId: ownerId });
|
||||
await client.respondMcpElicitation(threadId, 11, { action: "decline", content: null, _meta: null }, { ownerClientId: ownerId });
|
||||
|
||||
assert.deepEqual(requests.map((request) => request.method), [
|
||||
"thread-follower-start-turn",
|
||||
"thread-follower-steer-turn",
|
||||
"thread-follower-update-thread-settings",
|
||||
"thread-follower-interrupt-turn",
|
||||
"thread-follower-command-approval-decision",
|
||||
"thread-follower-file-approval-decision",
|
||||
"thread-follower-permissions-request-approval-response",
|
||||
"thread-follower-submit-user-input",
|
||||
"thread-follower-submit-mcp-server-elicitation-response",
|
||||
]);
|
||||
assert.deepEqual(requests[0].params, {
|
||||
conversationId: threadId,
|
||||
turnStart: {
|
||||
request: {
|
||||
threadId,
|
||||
input: [{ type: "text", text: "hello", text_elements: [] }],
|
||||
},
|
||||
context: { inheritThreadSettings: true },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(requests[2].params, {
|
||||
conversationId: threadId,
|
||||
threadSettings: {
|
||||
model: "gpt-5.6-sol",
|
||||
effort: "ultra",
|
||||
multiAgentMode: "explicitRequestOnly",
|
||||
},
|
||||
});
|
||||
assert.equal(requests[2].version, 1);
|
||||
assert.deepEqual(requests[3].params, {
|
||||
conversationId: threadId,
|
||||
mode: "user-stop",
|
||||
expectedTurnId: "turn-1",
|
||||
});
|
||||
assert.equal(requests[3].version, 4);
|
||||
assert.deepEqual(requests[4].params, { conversationId: threadId, requestId: 7, decision: "decline" });
|
||||
assert.deepEqual(requests[8].params, {
|
||||
conversationId: threadId,
|
||||
requestId: 11,
|
||||
response: { action: "decline", content: null, _meta: null },
|
||||
});
|
||||
await client.dispose();
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { resolveCodexCommand } = require("../vscode-extension/dist/codexPath.js");
|
||||
const { JsonlRpcClient } = require("../vscode-extension/dist/jsonlRpc.js");
|
||||
|
||||
function temporaryDirectory() {
|
||||
return mkdtempSync(path.join(os.tmpdir(), "codex-remote-path-"));
|
||||
}
|
||||
|
||||
test("resolveCodexCommand finds a bare command in PATH", () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const bin = path.join(root, "bin");
|
||||
const executable = path.join(bin, "codex-test");
|
||||
mkdirSync(bin);
|
||||
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(executable, 0o755);
|
||||
assert.equal(resolveCodexCommand("codex-test", { env: { PATH: bin }, platform: process.platform }), executable);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodexCommand falls back to a per-user ChatGPT.app install", () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const executable = path.join(root, "Applications", "ChatGPT.app", "Contents", "Resources", "codex");
|
||||
mkdirSync(path.dirname(executable), { recursive: true });
|
||||
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(executable, 0o755);
|
||||
assert.equal(
|
||||
resolveCodexCommand("codex", { env: { PATH: "/usr/bin:/bin" }, homeDir: root, platform: "darwin" }),
|
||||
executable,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing explicit command reports a full-path setting hint", () => {
|
||||
assert.throws(
|
||||
() => resolveCodexCommand("/definitely/missing/codex", { platform: process.platform }),
|
||||
/Codex executable .* was not found.*codexRemoteCollab\.codexCommand.*full path/,
|
||||
);
|
||||
});
|
||||
|
||||
test("JsonlRpcClient turns spawn ENOENT into an actionable error", async () => {
|
||||
const client = new JsonlRpcClient({ command: "/definitely/missing/codex", args: [] });
|
||||
await assert.rejects(() => client.start(), /Codex executable .* was not found.*codexRemoteCollab\.codexCommand/);
|
||||
client.close();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { CompositeRelayTransport } = require("../vscode-extension/dist/compositeRelay.js");
|
||||
|
||||
class FakeRelay {
|
||||
constructor({ connectError } = {}) {
|
||||
this.connectError = connectError;
|
||||
this.frames = [];
|
||||
this.closed = false;
|
||||
this.listeners = { message: new Set(), open: new Set(), close: new Set() };
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connectError) throw this.connectError;
|
||||
for (const listener of this.listeners.open) listener();
|
||||
}
|
||||
|
||||
send(frame) { this.frames.push(frame); }
|
||||
close() { this.closed = true; }
|
||||
onMessage(listener) { return this.add("message", listener); }
|
||||
onOpen(listener) { return this.add("open", listener); }
|
||||
onClose(listener) { return this.add("close", listener); }
|
||||
add(type, listener) {
|
||||
this.listeners[type].add(listener);
|
||||
return { dispose: () => this.listeners[type].delete(listener) };
|
||||
}
|
||||
receive(frame) { for (const listener of this.listeners.message) listener(frame); }
|
||||
disconnect(error) { for (const listener of this.listeners.close) listener(error); }
|
||||
}
|
||||
|
||||
test("CompositeRelayTransport keeps local control available when optional cloud connect fails", async () => {
|
||||
const local = new FakeRelay();
|
||||
const cloud = new FakeRelay({ connectError: new Error("cloud offline") });
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
|
||||
await relay.connect();
|
||||
assert.equal(relay.isConnected("local"), true);
|
||||
assert.equal(relay.isConnected("cloud"), false);
|
||||
relay.send({ kind: "event", type: "session.snapshot" });
|
||||
assert.equal(local.frames.length, 1);
|
||||
assert.equal(cloud.frames.length, 1, "optional transport may queue events for reconnect");
|
||||
relay.close();
|
||||
assert.equal(local.closed, true);
|
||||
assert.equal(cloud.closed, true);
|
||||
});
|
||||
|
||||
test("CompositeRelayTransport forwards commands and reports offline only after every relay closes", async () => {
|
||||
const local = new FakeRelay();
|
||||
const cloud = new FakeRelay();
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
const messages = [];
|
||||
const closes = [];
|
||||
relay.onMessage((frame) => messages.push(frame));
|
||||
relay.onClose((error) => closes.push(error?.message));
|
||||
|
||||
await relay.connect();
|
||||
assert.equal(relay.isConnected("local"), true);
|
||||
assert.equal(relay.isConnected("cloud"), true);
|
||||
cloud.receive({ kind: "command", type: "turn.start" });
|
||||
assert.equal(messages.length, 1);
|
||||
local.disconnect(new Error("local offline"));
|
||||
assert.equal(relay.isConnected("local"), false);
|
||||
assert.deepEqual(closes, []);
|
||||
cloud.disconnect(new Error("cloud offline"));
|
||||
assert.deepEqual(closes, ["cloud offline"]);
|
||||
relay.close();
|
||||
});
|
||||
|
||||
test("CompositeRelayTransport surfaces each member reconnect for snapshot hydration", async () => {
|
||||
const local = new FakeRelay();
|
||||
const cloud = new FakeRelay();
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
let opens = 0;
|
||||
relay.onOpen(() => { opens += 1; });
|
||||
await relay.connect();
|
||||
assert.equal(opens, 2);
|
||||
cloud.disconnect(new Error("cloud offline"));
|
||||
for (const listener of cloud.listeners.open) listener();
|
||||
assert.equal(opens, 3, "cloud recovery must prompt RelayHost to publish a fresh snapshot");
|
||||
relay.close();
|
||||
});
|
||||
|
||||
test("CompositeRelayTransport fails when the required local relay cannot connect", async () => {
|
||||
const local = new FakeRelay({ connectError: new Error("local offline") });
|
||||
const cloud = new FakeRelay();
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
await assert.rejects(relay.connect(), /local: local offline/);
|
||||
assert.equal(local.closed, true);
|
||||
assert.equal(cloud.closed, true);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
test("VS Code runtime strings have English and Simplified Chinese bundles", () => {
|
||||
const extensionRoot = path.join(__dirname, "..", "vscode-extension");
|
||||
const source = fs.readFileSync(path.join(extensionRoot, "src", "extension.ts"), "utf8");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8"));
|
||||
const english = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.json"), "utf8"));
|
||||
const chinese = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.zh-cn.json"), "utf8"));
|
||||
const keys = [...source.matchAll(/(?<![A-Za-z])t\("([^"]+)"/g)].map((match) => match[1]);
|
||||
|
||||
assert.equal(manifest.l10n, "./l10n");
|
||||
assert.ok(keys.length > 20, "expected runtime-localized extension strings");
|
||||
for (const key of new Set(keys)) {
|
||||
assert.equal(english[key], key, `missing English source string: ${key}`);
|
||||
assert.equal(typeof chinese[key], "string", `missing zh-CN translation: ${key}`);
|
||||
assert.ok(chinese[key].length > 0, `empty zh-CN translation: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("production copy scripts require the Vue build instead of silently falling back", () => {
|
||||
const projectRoot = path.join(__dirname, "..");
|
||||
const extensionSync = fs.readFileSync(path.join(projectRoot, "vscode-extension", "scripts", "sync-local-relay.cjs"), "utf8");
|
||||
const aetherSync = fs.readFileSync(path.join(projectRoot, "..", "frontend", "scripts", "sync-vscodex.mjs"), "utf8");
|
||||
const extensionManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "vscode-extension", "package.json"), "utf8"));
|
||||
|
||||
assert.match(extensionManifest.scripts["vscode:prepublish"], /build:web/);
|
||||
assert.doesNotMatch(extensionSync, /projectRoot,\s*"public"/);
|
||||
assert.doesNotMatch(aetherSync, /moduleRoot,\s*'public'/);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
LocalRelayController,
|
||||
localRelayTarget,
|
||||
relayHealthAvailable,
|
||||
} = require("../vscode-extension/dist/localRelay.js");
|
||||
|
||||
test("local relay target accepts only loopback ws URLs", () => {
|
||||
assert.deepEqual(localRelayTarget("ws://localhost:8898/v1/connect"), {
|
||||
host: "127.0.0.1",
|
||||
port: 8898,
|
||||
healthUrl: "http://127.0.0.1:8898/api/health",
|
||||
webUrl: "http://127.0.0.1:8898/",
|
||||
});
|
||||
assert.equal(localRelayTarget("wss://127.0.0.1:8898/v1/connect"), undefined);
|
||||
assert.equal(localRelayTarget("ws://192.168.1.10:8898/v1/connect"), undefined);
|
||||
assert.equal(localRelayTarget("not a url"), undefined);
|
||||
});
|
||||
|
||||
test("local relay health probe recognizes a responding HTTP service", async (t) => {
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === "/api/health") {
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
|
||||
} else if (request.url === "/aborted") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.write('{"ok":');
|
||||
response.destroy();
|
||||
} else if (request.url === "/drip") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
const interval = setInterval(() => response.write(" "), 10);
|
||||
response.on("close", () => clearInterval(interval));
|
||||
} else {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/api/health`), true);
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/missing`), false);
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/aborted`, 100), false);
|
||||
const startedAt = Date.now();
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/drip`, 50), false);
|
||||
assert.ok(Date.now() - startedAt < 500);
|
||||
});
|
||||
|
||||
test("local relay controller starts and stops a bundled loopback relay", async () => {
|
||||
let starts = 0;
|
||||
let stops = 0;
|
||||
class FakeRelay {
|
||||
async start() { starts += 1; return { host: "127.0.0.1", port: 65534 }; }
|
||||
async stop() { stops += 1; }
|
||||
}
|
||||
const controller = new LocalRelayController({
|
||||
extensionPath: path.resolve(__dirname, "../vscode-extension"),
|
||||
probeTimeoutMs: 20,
|
||||
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
|
||||
});
|
||||
assert.equal(await controller.ensureRunning("ws://127.0.0.1:65534/v1/connect"), true);
|
||||
assert.equal(starts, 1);
|
||||
await controller.stop();
|
||||
assert.equal(stops, 1);
|
||||
});
|
||||
|
||||
test("local relay controller does not leak a relay when stopped during startup", async () => {
|
||||
let releaseStart;
|
||||
const startGate = new Promise((resolve) => { releaseStart = resolve; });
|
||||
let startEntered;
|
||||
const entered = new Promise((resolve) => { startEntered = resolve; });
|
||||
let stops = 0;
|
||||
class SlowRelay {
|
||||
async start() {
|
||||
startEntered();
|
||||
await startGate;
|
||||
return { host: "127.0.0.1", port: 65533 };
|
||||
}
|
||||
async stop() { stops += 1; }
|
||||
}
|
||||
const controller = new LocalRelayController({
|
||||
extensionPath: path.resolve(__dirname, "../vscode-extension"),
|
||||
probeTimeoutMs: 20,
|
||||
loadRelayModule: () => ({ CodexRelay: SlowRelay }),
|
||||
});
|
||||
const starting = controller.ensureRunning("ws://127.0.0.1:65533/v1/connect");
|
||||
await entered;
|
||||
const stopping = controller.stop();
|
||||
releaseStart();
|
||||
await Promise.all([starting, stopping]);
|
||||
assert.equal(stops, 1);
|
||||
});
|
||||
|
||||
test("local relay controller does not start after stop wins an in-flight health probe", async () => {
|
||||
let resolveProbe;
|
||||
const probe = new Promise((resolve) => { resolveProbe = resolve; });
|
||||
let probeEntered;
|
||||
const entered = new Promise((resolve) => { probeEntered = resolve; });
|
||||
let starts = 0;
|
||||
class FakeRelay {
|
||||
async start() { starts += 1; return { host: "127.0.0.1", port: 65532 }; }
|
||||
async stop() {}
|
||||
}
|
||||
const controller = new LocalRelayController({
|
||||
extensionPath: path.resolve(__dirname, "../vscode-extension"),
|
||||
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
|
||||
probeRelayHealth: async () => {
|
||||
probeEntered();
|
||||
return probe;
|
||||
},
|
||||
});
|
||||
const ensuring = controller.ensureRunning("ws://127.0.0.1:65532/v1/connect");
|
||||
await entered;
|
||||
await controller.stop();
|
||||
resolveProbe(false);
|
||||
assert.equal(await ensuring, false);
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createAetherEmbedBridge, isAetherEmbed } = require("../public/embed-bridge.js");
|
||||
const i18n = require("../public/i18n.js");
|
||||
|
||||
function embeddedWindow() {
|
||||
const listeners = new Map();
|
||||
const posts = [];
|
||||
const parent = { postMessage: (message, origin) => posts.push({ message, origin }) };
|
||||
const bodyClasses = new Set();
|
||||
const documentElement = { dataset: {}, style: {} };
|
||||
const windowLike = {
|
||||
location: { search: "?embed=aether", origin: "https://aether.example" },
|
||||
parent,
|
||||
document: {
|
||||
body: { classList: { add: (value) => bodyClasses.add(value) } },
|
||||
documentElement,
|
||||
},
|
||||
addEventListener: (name, listener) => listeners.set(name, listener),
|
||||
removeEventListener: (name, listener) => {
|
||||
if (listeners.get(name) === listener) listeners.delete(name);
|
||||
},
|
||||
};
|
||||
return { bodyClasses, documentElement, listeners, parent, posts, windowLike };
|
||||
}
|
||||
|
||||
test("Aether embed mode is opt-in and announces readiness only to the same-origin parent", () => {
|
||||
assert.equal(isAetherEmbed({ search: "" }), false);
|
||||
assert.equal(isAetherEmbed({ search: "?embed=other" }), false);
|
||||
assert.equal(isAetherEmbed({ search: "?embed=aether" }), true);
|
||||
|
||||
const fixture = embeddedWindow();
|
||||
const bridge = createAetherEmbedBridge(fixture.windowLike);
|
||||
assert.equal(bridge.active, true);
|
||||
bridge.start();
|
||||
assert.equal(fixture.bodyClasses.has("embed-aether"), true);
|
||||
assert.deepEqual(fixture.posts, [{
|
||||
message: { v: 1, type: "aether-vscodex/ready" },
|
||||
origin: "https://aether.example",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("Aether embed bridge rejects cross-origin and non-parent messages and buffers an early connect", () => {
|
||||
const fixture = embeddedWindow();
|
||||
const bridge = createAetherEmbedBridge(fixture.windowLike);
|
||||
bridge.start();
|
||||
const dispatch = fixture.listeners.get("message");
|
||||
const connect = {
|
||||
v: 1,
|
||||
type: "aether-vscodex/connect",
|
||||
ticket: "one-time-ticket",
|
||||
wsUrl: "/api/vscodex/ws",
|
||||
locale: "en-US",
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
dispatch({ origin: "https://attacker.example", source: fixture.parent, data: connect });
|
||||
dispatch({ origin: "https://aether.example", source: {}, data: connect });
|
||||
let received = null;
|
||||
bridge.on("connect", (message) => { received = message; });
|
||||
assert.equal(received, null);
|
||||
|
||||
dispatch({ origin: "https://aether.example", source: fixture.parent, data: connect });
|
||||
assert.equal(received.ticket, "one-time-ticket");
|
||||
assert.equal(fixture.documentElement.dataset.theme, "dark");
|
||||
|
||||
const second = embeddedWindow();
|
||||
const bufferedBridge = createAetherEmbedBridge(second.windowLike);
|
||||
bufferedBridge.start();
|
||||
second.listeners.get("message")({ origin: "https://aether.example", source: second.parent, data: connect });
|
||||
let buffered = null;
|
||||
bufferedBridge.on("connect", (message) => { buffered = message; });
|
||||
assert.equal(buffered.ticket, "one-time-ticket");
|
||||
});
|
||||
|
||||
test("bridge ticket requests never place the ticket in a URL", () => {
|
||||
const fixture = embeddedWindow();
|
||||
const bridge = createAetherEmbedBridge(fixture.windowLike);
|
||||
bridge.start();
|
||||
bridge.requestTicket({ reason: "disconnected", deviceId: "device-1" });
|
||||
assert.deepEqual(fixture.posts.at(-1), {
|
||||
message: {
|
||||
v: 1,
|
||||
type: "aether-vscodex/request-ticket",
|
||||
reason: "disconnected",
|
||||
deviceId: "device-1",
|
||||
},
|
||||
origin: "https://aether.example",
|
||||
});
|
||||
});
|
||||
|
||||
test("locale dictionary covers static shell and core dynamic status text", () => {
|
||||
assert.equal(i18n.translate("设置", "en-US"), "Settings");
|
||||
assert.equal(i18n.translate("中文", "en-US"), "Chinese");
|
||||
assert.equal(i18n.translate("正在思考", "en-US"), "Thinking");
|
||||
assert.equal(i18n.translate("已读取这些内容 · 4 个文件", "en-US"), "Read these items · 4 files");
|
||||
assert.equal(i18n.translate("用时 3分45秒", "en-US"), "Worked for 3m45s");
|
||||
assert.equal(i18n.translate("修改权限,当前为需要时询问", "en-US"), "Change permissions. Current: Ask when needed");
|
||||
assert.equal(i18n.translate("模型设置更新失败:timeout", "en-US"), "Unable to update model settings: timeout");
|
||||
assert.equal(i18n.translate("请求 #17 已发送,等待 VS Code 主机确认", "en-US"), "Request #17 sent; waiting for the VS Code host");
|
||||
assert.equal(i18n.translate("无法读取 notes.md", "en-US"), "Unable to read notes.md");
|
||||
assert.equal(i18n.translate("命令: timed out", "en-US"), "Command: timed out");
|
||||
assert.equal(i18n.translate("命令: timed out(执行状态未知,请等待主机恢复)", "en-US"), "Command: timed out (execution status unknown; wait for the host to recover)");
|
||||
assert.equal(i18n.translate("子代理 失败", "en-US"), "Subagent failed");
|
||||
assert.equal(i18n.translate("已在 2秒 内运行 echo hi", "en-US"), "Ran echo hi in 2s");
|
||||
assert.equal(i18n.translate("命令运行失败 · echo hi · 2秒", "en-US"), "Command failed · echo hi · 2s");
|
||||
assert.equal(i18n.translate("命令运行失败 · echo hi", "en-US"), "Command failed · echo hi");
|
||||
assert.equal(i18n.translate("已停止 echo hi · 2秒", "en-US"), "Stopped echo hi · 2s");
|
||||
assert.equal(i18n.translate("文件变更 · 失败", "en-US"), "File changes · Failed");
|
||||
assert.equal(i18n.translate("文件变更 · 已中断", "en-US"), "File changes · Interrupted");
|
||||
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
|
||||
assert.equal(i18n.translate("命令 · 设置", "en-US"), "Command · 设置");
|
||||
assert.equal(i18n.translate("正在读取 设置", "en-US"), "Reading 设置");
|
||||
assert.equal(i18n.translate("已在 2秒 内运行 设置", "en-US"), "Ran 设置 in 2s");
|
||||
assert.equal(i18n.translate("正在切换到「设置」…", "en-US"), "Switching to “设置”...");
|
||||
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
|
||||
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
|
||||
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
|
||||
assert.equal(i18n.translate("已工具 · 2秒", "en-US"), "Tool completed · 2s");
|
||||
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
|
||||
assert.equal(i18n.translate("编辑了文件", "en-US"), "Edited files");
|
||||
assert.equal(i18n.translate("编辑了文件 · 2秒", "en-US"), "Edited files · 2s");
|
||||
assert.equal(i18n.translate("已完成计划", "en-US"), "Completed plan");
|
||||
assert.equal(i18n.translate("已完成计划 · 2秒", "en-US"), "Completed plan · 2s");
|
||||
assert.equal(i18n.translate("…(文件已截断)", "en-US"), "... (file truncated)");
|
||||
assert.equal(i18n.translate("事件窗口已过期,请以当前快照为准", "en-US"), "The event window expired; the current snapshot is authoritative");
|
||||
assert.equal(i18n.translate("控制模式", "en-US"), "Control mode");
|
||||
assert.equal(i18n.translate("同步模式跟随 VS Code 当前会话", "en-US"), "Sync mode follows the current VS Code conversation");
|
||||
assert.equal(i18n.translate("异步模式可独立管理会话", "en-US"), "Async mode manages conversations independently");
|
||||
assert.equal(i18n.translate("当前任务或请求完成后才能切换控制模式", "en-US"), "The control mode can be changed after the current task or request finishes");
|
||||
assert.equal(i18n.translate("Settings", "zh-CN"), "设置");
|
||||
assert.equal(i18n.normalizeLocale("zh-Hans"), "zh-CN");
|
||||
assert.equal(i18n.normalizeLocale("en-GB"), "en-US");
|
||||
});
|
||||
|
||||
test("renderer-owned dynamic labels have English fallbacks without translating host values", () => {
|
||||
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
|
||||
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
|
||||
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
|
||||
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
|
||||
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
|
||||
|
||||
const app = fs.readFileSync(path.join(__dirname, "..", "public", "app.js"), "utf8");
|
||||
// Command/path/title values are appended after a locale-specific prefix;
|
||||
// they are never passed through the translator as a whole.
|
||||
assert.match(app, /uiWithRaw\("正在运行 ", "Running ",/);
|
||||
assert.match(app, /uiWithRaw\("已读取 ", "Read ",/);
|
||||
assert.match(app, /uiLocale\(\) === "en-US" \? `Switching to/);
|
||||
});
|
||||
|
||||
test("public shell uses relative assets and embedded startup skips the health probe", () => {
|
||||
const publicRoot = path.join(__dirname, "..", "public");
|
||||
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
|
||||
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
|
||||
assert.match(html, /href="\.\/style\.css"/);
|
||||
assert.match(html, /src="\.\/embed-bridge\.js"/);
|
||||
assert.match(html, /src="\.\/i18n\.js"/);
|
||||
assert.match(html, /src="\.\/app\.js"/);
|
||||
assert.match(app, /if \(embeddedInAether\)[\s\S]+else \{[\s\S]+fetch\("\.\/api\/health"/);
|
||||
assert.doesNotMatch(app, /ticket=.*state\.embedTicket/);
|
||||
assert.match(app, /empty\.textContent = t\(activity\.status === "inProgress" \? "正在读取文件" : "读取完成"\)/);
|
||||
assert.match(app, /outputContent\.textContent = t\("无输出"\)/);
|
||||
assert.match(app, /button\.title = t\(title\)/);
|
||||
assert.match(app, /activity\.action === "spawnAgent" \? t\("启动子代理"\)/);
|
||||
assert.match(app, /return t\("需要远程确认或输入"\)/);
|
||||
assert.match(app, /questionPrompt === undefined \|\| questionPrompt === null \? t\("请输入"\)/);
|
||||
assert.match(app, /checkbox\.setAttribute\("aria-label", t\(checkbox\.checked \? "已完成" : "未完成"\)\)/);
|
||||
assert.match(app, /window\.addEventListener\("aether-vscodex:locale", \(\) => \{[\s\S]+state\.activities\.values\(\)[\s\S]+renderRequests\(\)/);
|
||||
});
|
||||
|
||||
test("control mode is snapshot-authoritative and gates independent session actions", () => {
|
||||
const publicRoot = path.join(__dirname, "..", "public");
|
||||
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
|
||||
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
|
||||
|
||||
assert.match(html, /id="controlModeSwitch"[\s\S]+data-control-mode="sync"[\s\S]+data-control-mode="async"/);
|
||||
assert.match(app, /command\("control\/mode\/set", \{ mode \}\)/);
|
||||
assert.match(app, /applyControlModeSnapshot\(payload\.metadata\)/);
|
||||
assert.match(app, /const controlMetadata = \{[\s\S]+snapshot\.metadata[\s\S]+appState\.sessionMetadata[\s\S]+applyControlModeSnapshot\(controlMetadata\)/);
|
||||
assert.match(app, /sessionList: source\.sessionList === true/);
|
||||
assert.match(app, /Boolean\(state\.sessionListCommandId\)/);
|
||||
assert.match(app, /mode_switch_pending.*return "正在切换控制模式"/);
|
||||
assert.match(app, /mode_busy\|cannot switch control mode.*return "当前任务或请求完成后才能切换控制模式"/);
|
||||
assert.match(app, /setConversationStatus\(sessionErrorMessage\(message, "控制模式切换失败"\), "warning"\)/);
|
||||
assert.match(app, /if \(!sessionControlAllowed\("sessionList"\)\) return;/);
|
||||
assert.match(app, /if \(!sessionControlAllowed\("sessionSelect"\)\)/);
|
||||
assert.match(app, /if \(!sessionControlAllowed\("sessionCreate"\)\)/);
|
||||
assert.match(app, /sessionPickerButton\.disabled = !listAllowed/);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
|
||||
const { RelayClient } = require("../vscode-extension/dist/relayClient.js");
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
static instances = [];
|
||||
|
||||
constructor(url) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
this.sent = [];
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
receive(frame) {
|
||||
this.emit("message", Buffer.from(JSON.stringify(frame)));
|
||||
}
|
||||
|
||||
send(data) {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.readyState === 3) return;
|
||||
this.readyState = 3;
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
test("RelayClient queues application frames until auth.ok on initial connect and reconnect", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const firstConnect = client.connect();
|
||||
const first = FakeWebSocket.instances[0];
|
||||
first.open();
|
||||
assert.deepEqual(first.sent.map((frame) => frame.kind), ["hello", "auth"]);
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-1", sessionId: "session-1", payload: { text: "queued" } });
|
||||
assert.equal(first.sent.length, 2, "application event must not be sent before authentication");
|
||||
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await firstConnect;
|
||||
assert.equal(first.sent.length, 3);
|
||||
assert.equal(first.sent[2].id, "event-1");
|
||||
|
||||
first.close();
|
||||
const secondConnect = client.connect();
|
||||
const second = FakeWebSocket.instances[1];
|
||||
second.open();
|
||||
assert.deepEqual(second.sent.map((frame) => frame.kind), ["hello", "auth"]);
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-2", sessionId: "session-1", payload: { text: "queued during reconnect" } });
|
||||
assert.equal(second.sent.length, 2, "reconnect window must remain auth-gated");
|
||||
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await secondConnect;
|
||||
assert.equal(second.sent.length, 3);
|
||||
assert.equal(second.sent[2].id, "event-2");
|
||||
});
|
||||
|
||||
test("RelayClient coalesces queued transcript projections within a byte budget", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
maxFrameBytes: 4_096,
|
||||
maxQueuedBytes: 4_096,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(700) } });
|
||||
client.send({ v: 1, kind: "event", type: "output.snapshot", id: "old-projection", sessionId: "session-1", payload: { text: "x".repeat(1_200) } });
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "new-projection", sessionId: "session-1", payload: { text: "y".repeat(1_200) } });
|
||||
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(700) } });
|
||||
|
||||
assert.ok(client.queueBytes <= 4_096);
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await connecting;
|
||||
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
|
||||
assert.deepEqual(queuedIds, ["approval", "new-projection", "command"]);
|
||||
});
|
||||
|
||||
test("RelayClient evicts reconstructible projections before queued control events", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
maxFrameBytes: 4_096,
|
||||
maxQueuedBytes: 2_500,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(850) } });
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "projection", sessionId: "session-1", payload: { text: "x".repeat(900) } });
|
||||
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(850) } });
|
||||
|
||||
assert.ok(client.queueBytes <= 2_500);
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await connecting;
|
||||
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
|
||||
assert.deepEqual(queuedIds, ["approval", "command"]);
|
||||
});
|
||||
|
||||
test("RelayClient supports a tokenless local handshake", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://127.0.0.1:8787/v1/connect",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
assert.deepEqual(socket.sent.map((frame) => frame.kind), ["hello"]);
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
|
||||
await connecting;
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "connection.opened", id: "event-local", sessionId: "session-local", payload: {} });
|
||||
assert.equal(socket.sent.length, 2);
|
||||
assert.equal(socket.sent[1].type, "connection.opened");
|
||||
});
|
||||
|
||||
test("RelayClient accepts structured history snapshots larger than the old 256 KiB limit", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://127.0.0.1:8787/v1/connect",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
|
||||
await connecting;
|
||||
|
||||
const historyText = "x".repeat(512 * 1024);
|
||||
assert.doesNotThrow(() => client.send({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: "session.snapshot",
|
||||
id: "large-history-snapshot",
|
||||
sessionId: "session-local",
|
||||
payload: { threadId: "large-thread", messages: [{ kind: "assistant", text: historyText }] },
|
||||
}));
|
||||
assert.equal(socket.sent.at(-1).payload.messages[0].text.length, historyText.length);
|
||||
});
|
||||
|
||||
test("RelayClient ignores late events from a replaced socket", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const firstConnect = client.connect();
|
||||
const first = FakeWebSocket.instances[0];
|
||||
first.open();
|
||||
client.close();
|
||||
|
||||
const secondConnect = client.connect();
|
||||
const second = FakeWebSocket.instances[1];
|
||||
second.open();
|
||||
|
||||
// Simulate a delayed event from the old socket after the replacement.
|
||||
first.open();
|
||||
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
assert.equal(second.sent.length, 2, "late auth must not authenticate or flush the new socket");
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-after-replace", sessionId: "session-1", payload: { text: "queued" } });
|
||||
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await secondConnect;
|
||||
assert.equal(second.sent[2].id, "event-after-replace");
|
||||
await assert.rejects(firstConnect);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { SwitchableAgentAdapter } = require("../vscode-extension/dist/switchableAgentAdapter.js");
|
||||
|
||||
class FakeAdapter {
|
||||
constructor(name, options = {}) {
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
this.listeners = new Set();
|
||||
this.calls = [];
|
||||
this.disposed = false;
|
||||
this.snapshotValue = options.snapshot ?? idleSnapshot(name);
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.calls.push(["start"]);
|
||||
this.emit({ type: "candidate.starting", payload: { name: this.name } });
|
||||
if (this.options.startGate) await this.options.startGate.promise;
|
||||
if (this.options.startError) throw this.options.startError;
|
||||
this.emit({ type: "connection.opened", payload: { name: this.name } });
|
||||
}
|
||||
|
||||
async startThread(params = {}) { return this.record("startThread", params); }
|
||||
async newSession(params = {}) { return this.record("newSession", params); }
|
||||
async startTurn(params) { return this.record("startTurn", params); }
|
||||
async steerTurn(params) { return this.record("steerTurn", params); }
|
||||
async updateThreadSettings(params) { return this.record("updateThreadSettings", params); }
|
||||
async listSessions(params = {}) { return this.record("listSessions", params); }
|
||||
async selectSession(params) { return this.record("selectSession", params); }
|
||||
async interruptTurn(params) { return this.record("interruptTurn", params); }
|
||||
async sendInput(text, params = {}) { return this.record("sendInput", { text, ...params }); }
|
||||
async cancel(taskId, params = {}) { return this.record("cancel", { taskId, ...params }); }
|
||||
async respondApproval(requestId, decision, reason, response) {
|
||||
return this.record("respondApproval", { requestId, decision, reason, response });
|
||||
}
|
||||
async denyPending(reason) { this.calls.push(["denyPending", reason]); }
|
||||
|
||||
async snapshot() {
|
||||
this.calls.push(["snapshot"]);
|
||||
return structuredClone(this.snapshotValue);
|
||||
}
|
||||
|
||||
onEvent(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
emit(event) {
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.calls.push(["dispose"]);
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
record(method, params) {
|
||||
this.calls.push([method, params]);
|
||||
return { adapter: this.name, method, params };
|
||||
}
|
||||
}
|
||||
|
||||
function idleSnapshot(name) {
|
||||
return {
|
||||
threadId: `${name}-thread`,
|
||||
turnId: null,
|
||||
state: "idle",
|
||||
pendingApprovals: [],
|
||||
pendingRequests: [],
|
||||
outputTail: "",
|
||||
metadata: { adapter: name },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test("sync mode decorates snapshots and enforces VS Code-owned navigation", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
|
||||
await adapter.start();
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.adapter, "sync");
|
||||
assert.equal(snapshot.metadata.mode, "sync");
|
||||
assert.equal(snapshot.metadata.controlMode, "sync");
|
||||
assert.equal(snapshot.metadata.modeEpoch, 0);
|
||||
assert.deepEqual(snapshot.metadata.capabilities, {
|
||||
followsVscodeRoute: true,
|
||||
sessionList: false,
|
||||
sessionSelect: false,
|
||||
sessionCreate: false,
|
||||
threadSettings: true,
|
||||
});
|
||||
|
||||
await assert.rejects(adapter.listSessions(), /unavailable in sync mode/);
|
||||
await assert.rejects(adapter.selectSession({ threadId: "other" }), /unavailable in sync mode/);
|
||||
await assert.rejects(adapter.newSession(), /unavailable in sync mode/);
|
||||
await assert.rejects(adapter.startThread(), /unavailable in sync mode/);
|
||||
assert.equal((await adapter.sendInput("hello")).adapter, "sync");
|
||||
assert.equal((await adapter.updateThreadSettings({ model: "codex" })).adapter, "sync");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async mode proxies the complete AgentAdapter surface", async () => {
|
||||
const independent = new FakeAdapter("async");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
|
||||
await adapter.start();
|
||||
|
||||
await adapter.startThread({ cwd: "/workspace" });
|
||||
await adapter.newSession({ model: "codex" });
|
||||
await adapter.startTurn({ text: "start" });
|
||||
await adapter.steerTurn({ text: "steer" });
|
||||
await adapter.updateThreadSettings({ effort: "high" });
|
||||
await adapter.listSessions({ limit: 10 });
|
||||
await adapter.selectSession({ threadId: "thread-2" });
|
||||
await adapter.interruptTurn({ turnId: "turn-1" });
|
||||
await adapter.sendInput("input", { source: "web" });
|
||||
await adapter.cancel("turn-2", { reason: "user" });
|
||||
await adapter.respondApproval(7, "allow", "approved", { decision: "accept" });
|
||||
await adapter.denyPending("offline");
|
||||
|
||||
assert.deepEqual(
|
||||
independent.calls.map(([method]) => method).filter((method) => !["start", "snapshot", "dispose"].includes(method)),
|
||||
[
|
||||
"startThread",
|
||||
"newSession",
|
||||
"startTurn",
|
||||
"steerTurn",
|
||||
"updateThreadSettings",
|
||||
"listSessions",
|
||||
"selectSession",
|
||||
"interruptTurn",
|
||||
"sendInput",
|
||||
"cancel",
|
||||
"respondApproval",
|
||||
"denyPending",
|
||||
],
|
||||
);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("session/new falls back to thread/start for a minimal async adapter", async () => {
|
||||
const independent = new FakeAdapter("async");
|
||||
independent.newSession = undefined;
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.newSession({ cwd: "/workspace" });
|
||||
assert.equal(result.method, "startThread");
|
||||
assert.equal((await adapter.snapshot()).metadata.capabilities.sessionCreate, true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("mode switch commits atomically, buffers candidate events, and isolates the old generation", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const gate = deferred();
|
||||
const asyncAdapter = new FakeAdapter("async", { startGate: gate });
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
||||
});
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(`${event.type}:${event.payload.name ?? event.payload.controlMode ?? ""}`));
|
||||
await adapter.start();
|
||||
events.length = 0;
|
||||
|
||||
const switching = adapter.setControlMode({ mode: "async" });
|
||||
await Promise.resolve();
|
||||
sync.emit({ type: "old.while-current", payload: { name: "sync" } });
|
||||
assert.deepEqual(events, ["old.while-current:sync"]);
|
||||
await assert.rejects(adapter.sendInput("racing input"), /mode is switching/);
|
||||
gate.resolve();
|
||||
|
||||
const result = await switching;
|
||||
assert.deepEqual(result, {
|
||||
changed: true,
|
||||
controlMode: "async",
|
||||
previousControlMode: "sync",
|
||||
modeEpoch: 1,
|
||||
});
|
||||
assert.equal(sync.disposed, true);
|
||||
assert.equal(adapter.getControlMode(), "async");
|
||||
assert.ok(events.indexOf("control.mode.changed:async") < events.indexOf("candidate.starting:async"));
|
||||
assert.ok(events.includes("connection.opened:async"));
|
||||
|
||||
sync.emit({ type: "old.after-commit", payload: { name: "sync" } });
|
||||
asyncAdapter.emit({ type: "new.after-commit", payload: { name: "async" } });
|
||||
assert.equal(events.includes("old.after-commit:sync"), false);
|
||||
assert.equal(events.includes("new.after-commit:async"), true);
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.modeEpoch, 1);
|
||||
assert.deepEqual(snapshot.metadata.capabilities, {
|
||||
followsVscodeRoute: false,
|
||||
sessionList: true,
|
||||
sessionSelect: true,
|
||||
sessionCreate: true,
|
||||
threadSettings: true,
|
||||
});
|
||||
assert.equal((await adapter.listSessions()).adapter, "async");
|
||||
assert.equal((await adapter.newSession()).adapter, "async");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("delegate snapshot events always carry authoritative mode metadata", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const asyncAdapter = new FakeAdapter("async");
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
||||
});
|
||||
const snapshots = [];
|
||||
adapter.onEvent((event) => {
|
||||
if (event.type === "session.snapshot") snapshots.push(event.payload);
|
||||
});
|
||||
await adapter.start();
|
||||
|
||||
sync.emit({
|
||||
type: "session.snapshot",
|
||||
threadId: "sync-thread-2",
|
||||
payload: { threadId: "sync-thread-2", metadata: { adapter: "sync", route: "/thread/2" } },
|
||||
});
|
||||
assert.deepEqual(snapshots.at(-1).metadata, {
|
||||
adapter: "sync",
|
||||
route: "/thread/2",
|
||||
mode: "sync",
|
||||
controlMode: "sync",
|
||||
modeEpoch: 0,
|
||||
capabilities: {
|
||||
followsVscodeRoute: true,
|
||||
sessionList: false,
|
||||
sessionSelect: false,
|
||||
sessionCreate: false,
|
||||
threadSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
await adapter.setControlMode({ mode: "async" });
|
||||
snapshots.length = 0;
|
||||
asyncAdapter.emit({
|
||||
type: "session.snapshot",
|
||||
threadId: "async-thread-2",
|
||||
payload: { threadId: "async-thread-2", metadata: { adapter: "async", title: "Second" } },
|
||||
});
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0].metadata.adapter, "async");
|
||||
assert.equal(snapshots[0].metadata.title, "Second");
|
||||
assert.equal(snapshots[0].metadata.controlMode, "async");
|
||||
assert.equal(snapshots[0].metadata.modeEpoch, 1);
|
||||
assert.equal(snapshots[0].metadata.capabilities.followsVscodeRoute, false);
|
||||
assert.equal(snapshots[0].metadata.capabilities.sessionSelect, true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("active turns and pending requests prevent a mode switch", async (t) => {
|
||||
const cases = [
|
||||
["active turn", { ...idleSnapshot("sync"), turnId: "turn-1", state: "active" }],
|
||||
["active state before a turn id arrives", { ...idleSnapshot("sync"), state: "in_progress" }],
|
||||
["active runtime flag", { ...idleSnapshot("sync"), activeFlags: ["thinking"] }],
|
||||
["pending approval", {
|
||||
...idleSnapshot("sync"),
|
||||
pendingApprovals: [{ requestId: 1, method: "approval", action: "run", risk: "low", summary: "run", createdAt: 1, payload: {} }],
|
||||
}],
|
||||
["pending input", {
|
||||
...idleSnapshot("sync"),
|
||||
pendingRequests: [{ requestId: "input-1", method: "item/tool/requestUserInput" }],
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, snapshot] of cases) {
|
||||
await t.test(name, async () => {
|
||||
const sync = new FakeAdapter("sync", { snapshot });
|
||||
let factoryCalls = 0;
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => {
|
||||
factoryCalls += 1;
|
||||
return mode === "sync" ? sync : new FakeAdapter("async");
|
||||
},
|
||||
});
|
||||
await adapter.start();
|
||||
await assert.rejects(adapter.setControlMode({ mode: "async" }), /turn or request is active/);
|
||||
assert.equal(factoryCalls, 1, "busy checks happen before creating a second adapter");
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
await adapter.dispose();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("candidate startup failure leaves the old adapter authoritative", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const failed = new FakeAdapter("async", { startError: new Error("candidate failed") });
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : failed,
|
||||
});
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(event.type));
|
||||
await adapter.start();
|
||||
events.length = 0;
|
||||
|
||||
await assert.rejects(adapter.setControlMode({ controlMode: "async" }), /candidate failed/);
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
assert.equal(failed.disposed, true);
|
||||
assert.equal(sync.disposed, false);
|
||||
assert.equal(events.includes("candidate.starting"), false, "failed candidate events stay private");
|
||||
assert.equal((await adapter.sendInput("still attached")).adapter, "sync");
|
||||
assert.equal((await adapter.snapshot()).metadata.modeEpoch, 0);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("a mode factory cannot reuse the currently active adapter instance", async () => {
|
||||
const shared = new FakeAdapter("shared");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => shared });
|
||||
await adapter.start();
|
||||
|
||||
await assert.rejects(adapter.setControlMode({ mode: "async" }), /must return a distinct adapter/);
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
assert.equal(shared.disposed, false);
|
||||
assert.equal((await adapter.sendInput("still live")).adapter, "shared");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("listener failures cannot turn a committed switch into a rejected command", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const asyncAdapter = new FakeAdapter("async");
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
||||
});
|
||||
adapter.onEvent(() => { throw new Error("consumer failed"); });
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.setControlMode({ mode: "async" });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(adapter.getControlMode(), "async");
|
||||
assert.equal(sync.disposed, true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("a turn that appears while the candidate starts aborts before commit", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const gate = deferred();
|
||||
const candidate = new FakeAdapter("async", { startGate: gate });
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : candidate,
|
||||
});
|
||||
await adapter.start();
|
||||
|
||||
const switching = adapter.setControlMode({ mode: "async" });
|
||||
await Promise.resolve();
|
||||
sync.snapshotValue.turnId = "turn-race";
|
||||
sync.snapshotValue.state = "active";
|
||||
gate.resolve();
|
||||
|
||||
await assert.rejects(switching, /turn or request is active/);
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
assert.equal(candidate.disposed, true);
|
||||
assert.equal(sync.disposed, false);
|
||||
sync.snapshotValue.turnId = null;
|
||||
sync.snapshotValue.state = "idle";
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("control mode validation and idempotent switches are explicit", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
|
||||
await adapter.start();
|
||||
|
||||
await assert.rejects(adapter.setControlMode({ mode: "attach" }), /must be sync or async/);
|
||||
assert.deepEqual(await adapter.setControlMode({ mode: "sync" }), {
|
||||
changed: false,
|
||||
controlMode: "sync",
|
||||
previousControlMode: "sync",
|
||||
modeEpoch: 0,
|
||||
});
|
||||
await adapter.dispose();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.vsix
|
||||
@@ -0,0 +1,9 @@
|
||||
src/**
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
**/*.map
|
||||
node_modules/@types/**
|
||||
node_modules/typescript/**
|
||||
node_modules/.package-lock.json
|
||||
*.tsbuildinfo
|
||||
*.vsix
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Codex Remote Collaboration contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Codex Remote Collaboration VS Code Bridge
|
||||
|
||||
This extension connects local and Aether relay channels to one switchable Codex
|
||||
control host. **Synchronous mode** follows the conversation currently shown by
|
||||
the official Codex VS Code extension through its private IPC protocol and does
|
||||
not spawn a `codex` process. **Asynchronous mode** starts an independent
|
||||
app-server and lets the Web UI list, resume, create, and select conversations.
|
||||
|
||||
The attached conversation remains visible and usable in the official Codex
|
||||
panel. Remote operators can observe its output, submit a new turn or steer the
|
||||
active turn, interrupt it, and answer supported approval/input requests.
|
||||
|
||||
The mode can be changed from the Web UI without reconnecting either relay.
|
||||
Synchronous mode makes the official panel the only conversation-navigation
|
||||
owner; asynchronous mode restores the browser history and new-conversation
|
||||
actions. A running turn or pending request blocks mode changes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The official `openai.chatgpt` VS Code extension is installed and signed in.
|
||||
- The target Codex conversation is open and owned by that extension.
|
||||
- The bridge and official extension run as the same OS user. The default Unix
|
||||
socket is `$CODEX_HOME/ipc/ipc.sock`, normally `~/.codex/ipc/ipc.sock`.
|
||||
- For a loopback `ws://` URL, the extension starts and owns its bundled relay
|
||||
automatically. Remote and `wss://` relay URLs remain externally hosted.
|
||||
|
||||
The IPC follower protocol is private and versioned, not a public OpenAI API.
|
||||
An official extension update can require a compatible bridge update. Strict
|
||||
stream-version checks are enabled by default so an unknown protocol fails
|
||||
closed instead of being interpreted optimistically.
|
||||
|
||||
## Build and install
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run check
|
||||
npm run build
|
||||
npx --yes @vscode/vsce package
|
||||
code --install-extension codex-remote-collab-0.4.0.vsix --force
|
||||
```
|
||||
|
||||
Run **Developer: Reload Window** after installing or replacing the VSIX.
|
||||
|
||||
## Configure control modes
|
||||
|
||||
For the local default, no separate relay command is required. The extension
|
||||
starts the bundled relay on the host and port from `codexRemoteCollab.localRelayUrl`.
|
||||
To run the development relay manually, disable
|
||||
`codexRemoteCollab.autoStartLocalRelay` and use:
|
||||
|
||||
```sh
|
||||
HOST=127.0.0.1 PORT=8787 CODEX_REMOTE_MODE=host npm start
|
||||
```
|
||||
|
||||
To opt into authentication later, set `CODEX_REMOTE_AUTH=required` and the three
|
||||
token variables before starting the relay.
|
||||
|
||||
Set the extension configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
|
||||
"codexRemoteCollab.controlMode": "sync",
|
||||
"codexRemoteCollab.autoDiscoverThread": true,
|
||||
"codexRemoteCollab.autoStart": true
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. Open the target conversation in the official Codex panel.
|
||||
2. Reload VS Code once after installing the companion extension. The local relay and bridge start automatically; no token is needed for loopback. The status item opens the Web console and is not a connect/disconnect toggle.
|
||||
3. Open the relay web console; it connects automatically on localhost. The web UI uses a
|
||||
Codex-style conversation stream with a bottom composer; Enter sends and Shift+Enter
|
||||
inserts a newline. There is no separate connect/disconnect step for the local relay.
|
||||
|
||||
If the browser says that it is waiting for the VS Code host or the recent-session list is
|
||||
empty, verify that `codexRemoteCollab.localRelayUrl` uses the same port as the relay and run
|
||||
**Developer: Reload Window**. Keep `codexRemoteCollab.threadId` empty unless a specific
|
||||
conversation must be pinned; an old closed ID can prevent startup until it is cleared.
|
||||
|
||||
When authentication is enabled, run **Codex Remote: Set Relay Token** with the
|
||||
host token. It is stored in `vscode.SecretStorage`, not in settings; the browser
|
||||
uses the operator or viewer token separately.
|
||||
|
||||
With no configured thread ID, the bridge ranks recent VS Code rollout metadata
|
||||
and shows only candidates verified by live IPC owner discovery and a matching
|
||||
follower snapshot. Explicit Codex Desktop tasks, closed, stale, and other
|
||||
non-attachable history entries are omitted.
|
||||
In synchronous mode, switching the conversation in the official Codex panel
|
||||
also switches the Web projection after the new owner snapshot is ready. The
|
||||
Web UI cannot list, select, or create conversations in this mode. Switch to
|
||||
asynchronous mode when the browser should own conversation navigation.
|
||||
To avoid ambiguity when several Codex windows are open, run **Codex Remote: Set Existing Thread ID**.
|
||||
An empty value restores automatic discovery.
|
||||
|
||||
Useful commands:
|
||||
|
||||
- **Codex Remote: Start Bridge** / **Stop Bridge**
|
||||
- **Codex Remote: Set Existing Thread ID**
|
||||
- **Codex Remote: Set Relay Token**
|
||||
- **Codex Remote: Pair with Aether**
|
||||
- **Codex Remote: Configure Aether Cloud Relay**
|
||||
- **Codex Remote: Send Input**
|
||||
- **Codex Remote: Show Snapshot**
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `codexRemoteCollab.controlMode` | `sync` | `sync` follows VS Code; `async` owns an independent app-server. |
|
||||
| `codexRemoteCollab.localRelayUrl` | `ws://127.0.0.1:8787/v1/connect` | Bundled loopback relay used by the local Web control. |
|
||||
| `codexRemoteCollab.aetherUrl` | empty | Aether origin remembered by the pairing command. |
|
||||
| `codexRemoteCollab.cloudRelayUrl` | empty | Aether WebSocket relay URL populated by pairing. |
|
||||
| `codexRemoteCollab.threadId` | empty | Exact existing conversation ID; empty enables discovery. |
|
||||
| `codexRemoteCollab.autoDiscoverThread` | `true` | Discover and owner-check a local VS Code session. |
|
||||
| `codexRemoteCollab.followVscodeSession` | `true` | Legacy compatibility setting; synchronous mode always follows VS Code. |
|
||||
| `codexRemoteCollab.ipcSocketPath` | empty | Override the local IPC socket path. |
|
||||
| `codexRemoteCollab.hostId` | `local` | Owner-discovery host identifier. |
|
||||
| `codexRemoteCollab.ipcStrictVersions` | `true` | Reject unsupported stream protocol versions. |
|
||||
| `codexRemoteCollab.approvalTimeoutMs` | `300000` | Deny an unanswered request locally after this delay. |
|
||||
| `codexRemoteCollab.allowHighRiskApprovals` | `false` | Permit remote high-risk approvals when explicitly enabled. |
|
||||
|
||||
`codexRemoteCollab.codexCommand`, `codexArgs`, and `defaultCwd` apply only to
|
||||
asynchronous mode. The deprecated `mode=attach/spawn` values map to
|
||||
`controlMode=sync/async` when no explicit control mode exists.
|
||||
|
||||
## Pair with Aether
|
||||
|
||||
The local relay stays enabled after cloud pairing. In Aether, open **Codex remote
|
||||
control** and generate a one-time code. Then run **Codex Remote: Pair with Aether**
|
||||
from the VS Code Command Palette, enter the Aether server URL and the code, and
|
||||
the bridge will connect to both relays. The long-lived device credential is stored
|
||||
only in VS Code SecretStorage. Revoke a lost or retired device from the Aether page.
|
||||
|
||||
## Relay behavior
|
||||
|
||||
The bridge sends a `hello` and, when a relay token is configured, a separate
|
||||
bearer-auth frame over an outbound WebSocket. It publishes normalized events including:
|
||||
|
||||
- `connection.opened` / `connection.closed`
|
||||
- `session.snapshot`
|
||||
- `output.snapshot` / `output.chunk`
|
||||
- `task.started` / `task.finished` / `task.cancelled`
|
||||
- `approval.requested` / `approval.resolved` / `approval.expired`
|
||||
- `input.requested` / `input.resolved` / `input.expired`
|
||||
|
||||
Remote commands are mapped to the existing conversation owner:
|
||||
|
||||
- `control/mode/set` atomically switches between `sync` and `async`.
|
||||
- `session/list`, `session/select`, and `session/new` are available only in
|
||||
asynchronous mode and map to `thread/list`, `thread/resume`, and `thread/start`.
|
||||
- `turn/start` starts a turn in the attached thread.
|
||||
- `turn/steer` adds input to the active turn.
|
||||
- `turn/interrupt` interrupts the expected active turn.
|
||||
- `approval.respond`, `input.respond`, and `server.request.respond` preserve the
|
||||
original request ID and use method-specific follower responses.
|
||||
- `thread/start` is deliberately rejected in synchronous mode because VS Code
|
||||
owns conversation navigation there.
|
||||
|
||||
The browser never connects directly to the IPC socket. Relay and host both
|
||||
enforce role/capability checks; high-risk command approval remains disabled
|
||||
unless the local VS Code setting opts in.
|
||||
|
||||
## Supported follower requests
|
||||
|
||||
- `item/commandExecution/requestApproval`
|
||||
- `item/fileChange/requestApproval`
|
||||
- `item/permissions/requestApproval`
|
||||
- `item/tool/requestUserInput`
|
||||
- `mcpServer/elicitation/request`
|
||||
- legacy `applyPatchApproval` and `execCommandApproval`
|
||||
|
||||
Unanswered requests expire with a local deny. JSON-RPC numeric and string IDs
|
||||
remain distinct, and a response can be submitted only once.
|
||||
|
||||
## Legacy mode migration
|
||||
|
||||
The old setting remains accepted:
|
||||
|
||||
```json
|
||||
{
|
||||
"codexRemoteCollab.mode": "spawn",
|
||||
"codexRemoteCollab.codexCommand": "/absolute/path/to/codex",
|
||||
"codexRemoteCollab.codexArgs": ["app-server", "--stdio"]
|
||||
}
|
||||
```
|
||||
|
||||
It maps to `controlMode=async`. Prefer the new setting directly. A
|
||||
`spawn codex ENOENT` error belongs only to asynchronous mode; it is not a
|
||||
synchronous-mode prerequisite or a PATH problem that needs fixing for
|
||||
existing-session control.
|
||||
|
||||
The standalone `npm run start:stdio` entry point and `createBridge()` helper
|
||||
also retain the legacy app-server adapter for compatibility.
|
||||
|
||||
## Embedding the attach adapter
|
||||
|
||||
The reusable exports are in `src/index.ts`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CodexIpcAgentAdapter,
|
||||
RelayClient,
|
||||
RelayHost,
|
||||
} from "codex-remote-collab";
|
||||
|
||||
const adapter = new CodexIpcAgentAdapter({
|
||||
threadId: process.env.CODEX_THREAD_ID,
|
||||
autoDiscoverThread: true,
|
||||
});
|
||||
const relay = new RelayClient({
|
||||
url: "wss://relay.example.test/v1/connect",
|
||||
accessToken: process.env.CODEX_REMOTE_HOST_TOKEN,
|
||||
});
|
||||
const host = new RelayHost({ adapter, relay });
|
||||
await host.start();
|
||||
```
|
||||
|
||||
`CodexIpcClient` is exported separately for protocol fixtures and diagnostics.
|
||||
Use `followConversation()` before follower mutations, and always target the
|
||||
owner returned by `findThreadOwner()`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No existing session found:** open the target official Codex conversation,
|
||||
keep that VS Code window running, then retry or set its exact thread ID.
|
||||
- **Owner not found:** the rollout exists on disk but no live official client
|
||||
currently owns it. Reopen the conversation in the Codex panel.
|
||||
- **IPC version mismatch:** update this bridge for the installed official
|
||||
extension. Disabling strict versions is diagnostic only.
|
||||
- **Relay stays at waiting for host:** confirm host mode, relay URL, and that no
|
||||
second host is already connected. If authentication is enabled, also check the
|
||||
host token.
|
||||
- **Old `spawn codex ENOENT` message:** install version `0.4.0`, reload VS Code,
|
||||
and verify `codexRemoteCollab.controlMode` is `sync` unless independent
|
||||
conversations are intended.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"A non-empty Aether device credential is required.": "A non-empty Aether device credential is required.",
|
||||
"Aether cloud connection removed. Local control remains enabled.": "Aether cloud connection removed. Local control remains enabled.",
|
||||
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.",
|
||||
"Aether cloud relay WebSocket URL": "Aether cloud relay WebSocket URL",
|
||||
"Aether pairing completed. Local and cloud control are both active.": "Aether pairing completed. Local and cloud control are both active.",
|
||||
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.",
|
||||
"Aether returned an invalid pairing response.": "Aether returned an invalid pairing response.",
|
||||
"Aether server URL": "Aether server URL",
|
||||
"Attached to the existing Codex conversation. Click to open the web control.": "Attached to the existing Codex conversation. Click to open the web control.",
|
||||
"Bridge connected. Click to open the web control.": "Bridge connected. Click to open the web control.",
|
||||
"Bridge paused. Click to open the web control and resume automatically.": "Bridge paused. Click to open the web control and resume automatically.",
|
||||
"Codex Remote Collaboration": "Codex Remote Collaboration",
|
||||
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote will attach to {0} after the next bridge start.",
|
||||
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.",
|
||||
"Connecting to the local Codex collaboration service": "Connecting to the local Codex collaboration service",
|
||||
"Device credential from the Aether pairing flow": "Device credential from the Aether pairing flow",
|
||||
"Enter a valid URL.": "Enter a valid URL.",
|
||||
"Enter a valid WebSocket URL.": "Enter a valid WebSocket URL.",
|
||||
"Enter the 8-character pairing code.": "Enter the 8-character pairing code.",
|
||||
"Enter the Aether server URL.": "Enter the Aether server URL.",
|
||||
"Existing Codex conversation ID (leave blank for auto-discovery)": "Existing Codex conversation ID (leave blank for auto-discovery)",
|
||||
"Independent Codex mode is connected. Click to open the web control.": "Independent Codex mode is connected. Click to open the web control.",
|
||||
"One-time pairing code shown in Aether": "One-time pairing code shown in Aether",
|
||||
"Relay access token (leave blank for the local relay)": "Relay access token (leave blank for the local relay)",
|
||||
"Relay token stored in VS Code SecretStorage.": "Relay token stored in VS Code SecretStorage.",
|
||||
"Remote Aether connections must use wss://.": "Remote Aether connections must use wss://.",
|
||||
"Remote Aether servers must use https://.": "Remote Aether servers must use https://.",
|
||||
"Restoring the local collaboration service": "Restoring the local collaboration service",
|
||||
"Send input to the active Codex turn": "Send input to the active Codex turn",
|
||||
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "Set codexRemoteCollab.localRelayUrl before starting the bridge.",
|
||||
"Start the Codex remote bridge first.": "Start the Codex remote bridge first.",
|
||||
"Starting the independent Codex mode.": "Starting the independent Codex mode.",
|
||||
"Starting {0}": "Starting {0}",
|
||||
"The Codex conversation is not connected": "The Codex conversation is not connected",
|
||||
"The Codex executable is unavailable": "The Codex executable is unavailable",
|
||||
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "The Codex remote bridge attached to the existing VS Code Codex conversation.",
|
||||
"The Codex remote bridge is already running.": "The Codex remote bridge is already running.",
|
||||
"The Codex remote collaboration bridge connected.": "The Codex remote collaboration bridge connected.",
|
||||
"The independent Codex mode is not connected": "The independent Codex mode is not connected",
|
||||
"The independent Codex remote mode connected.": "The independent Codex remote mode connected.",
|
||||
"The bridge is not connected": "The bridge is not connected",
|
||||
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.",
|
||||
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.",
|
||||
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.",
|
||||
"Unable to pair with Aether: {0}": "Unable to pair with Aether: {0}",
|
||||
"Unable to restore the local collaboration service": "Unable to restore the local collaboration service",
|
||||
"Unable to send Codex input: {0}": "Unable to send Codex input: {0}",
|
||||
"Unable to start the Codex remote bridge: {0}": "Unable to start the Codex remote bridge: {0}",
|
||||
"Unable to start the local Codex collaboration service: {0}": "Unable to start the local Codex collaboration service: {0}",
|
||||
"Unable to start the local collaboration service: {0}": "Unable to start the local collaboration service: {0}",
|
||||
"Use a ws:// or wss:// URL.": "Use a ws:// or wss:// URL.",
|
||||
"Use the Aether origin without credentials, a query, or a fragment.": "Use the Aether origin without credentials, a query, or a fragment.",
|
||||
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "Waiting for a Codex conversation to open in VS Code. It will connect automatically.",
|
||||
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl must be a loopback ws:// address."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"A non-empty Aether device credential is required.": "必须填写 Aether 设备凭据。",
|
||||
"Aether cloud connection removed. Local control remains enabled.": "已移除 Aether 云端连接,本地控制仍然可用。",
|
||||
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "已保存 Aether 云端连接。重启 Codex Remote 桥接后即可连接,本地控制仍然可用。",
|
||||
"Aether cloud relay WebSocket URL": "Aether 云端 relay WebSocket 地址",
|
||||
"Aether pairing completed. Local and cloud control are both active.": "Aether 配对完成,本地与云端控制均已启用。",
|
||||
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether 配对信息已保存,但当前无法连接云端。本地控制仍然可用,云端连接会继续重试。",
|
||||
"Aether returned an invalid pairing response.": "Aether 返回了无效的配对响应。",
|
||||
"Aether server URL": "Aether 服务器地址",
|
||||
"Attached to the existing Codex conversation. Click to open the web control.": "已附加到现有 Codex 会话,点击打开 Web 控制页。",
|
||||
"Bridge connected. Click to open the web control.": "桥接已连接,点击打开 Web 控制页。",
|
||||
"Bridge paused. Click to open the web control and resume automatically.": "桥接已暂停,点击打开 Web 控制页时会自动恢复。",
|
||||
"Codex Remote Collaboration": "Codex 远程协同",
|
||||
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote 将在下次启动桥接后附加到 {0}。",
|
||||
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote 将在下次启动桥接后自动发现最新的 VS Code Codex 会话。",
|
||||
"Connecting to the local Codex collaboration service": "正在连接本地 Codex 协同服务",
|
||||
"Device credential from the Aether pairing flow": "Aether 配对流程生成的设备凭据",
|
||||
"Enter a valid URL.": "请输入有效的 URL。",
|
||||
"Enter a valid WebSocket URL.": "请输入有效的 WebSocket URL。",
|
||||
"Enter the 8-character pairing code.": "请输入 8 位配对码。",
|
||||
"Enter the Aether server URL.": "请输入 Aether 服务器地址。",
|
||||
"Existing Codex conversation ID (leave blank for auto-discovery)": "现有 Codex 会话 ID(留空则自动发现)",
|
||||
"Independent Codex mode is connected. Click to open the web control.": "独立 Codex 模式已连接,点击打开 Web 控制页。",
|
||||
"One-time pairing code shown in Aether": "Aether 中显示的一次性配对码",
|
||||
"Relay access token (leave blank for the local relay)": "Relay 访问 token(本地 relay 请留空)",
|
||||
"Relay token stored in VS Code SecretStorage.": "Relay token 已保存到 VS Code SecretStorage。",
|
||||
"Remote Aether connections must use wss://.": "远程 Aether 连接必须使用 wss://。",
|
||||
"Remote Aether servers must use https://.": "远程 Aether 服务器必须使用 https://。",
|
||||
"Restoring the local collaboration service": "正在恢复本地协同服务",
|
||||
"Send input to the active Codex turn": "向当前 Codex turn 发送输入",
|
||||
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "请先设置 codexRemoteCollab.localRelayUrl,再启动桥接。",
|
||||
"Start the Codex remote bridge first.": "请先启动 Codex 远程桥接。",
|
||||
"Starting the independent Codex mode.": "正在启动独立 Codex 模式。",
|
||||
"Starting {0}": "正在启动 {0}",
|
||||
"The Codex conversation is not connected": "Codex 会话尚未连接",
|
||||
"The Codex executable is unavailable": "Codex 可执行文件不可用",
|
||||
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "Codex 远程桥接已附加到现有 VS Code Codex 会话。",
|
||||
"The Codex remote bridge is already running.": "Codex 远程桥接已在运行。",
|
||||
"The Codex remote collaboration bridge connected.": "Codex 远程协同桥接已连接。",
|
||||
"The independent Codex mode is not connected": "独立 Codex 模式尚未连接",
|
||||
"The independent Codex remote mode connected.": "独立 Codex 远程模式已连接。",
|
||||
"The bridge is not connected": "桥接尚未连接",
|
||||
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "本地协同地址无效,请检查 codexRemoteCollab.localRelayUrl。",
|
||||
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "本地协同服务 {0} 暂时无法连接,扩展会继续重试。",
|
||||
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "未找到官方 Codex 扩展的新会话命令,请确认 VS Code Codex 扩展已启用。",
|
||||
"Unable to pair with Aether: {0}": "无法与 Aether 配对:{0}",
|
||||
"Unable to restore the local collaboration service": "无法恢复本地协同服务",
|
||||
"Unable to send Codex input: {0}": "无法发送 Codex 输入:{0}",
|
||||
"Unable to start the Codex remote bridge: {0}": "无法启动 Codex 远程桥接:{0}",
|
||||
"Unable to start the local Codex collaboration service: {0}": "无法启动本地 Codex 协同服务:{0}",
|
||||
"Unable to start the local collaboration service: {0}": "无法启动本地协同服务:{0}",
|
||||
"Use a ws:// or wss:// URL.": "请使用 ws:// 或 wss:// URL。",
|
||||
"Use the Aether origin without credentials, a query, or a fragment.": "请填写不含凭据、查询参数或片段的 Aether 源地址。",
|
||||
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "正在等待 VS Code 中打开 Codex 会话,检测到后会自动连接。",
|
||||
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl 必须是回环地址上的 ws:// URL。"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "codex-remote-collab",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codex-remote-collab",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/vscode": {
|
||||
"version": "1.134.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz",
|
||||
"integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"name": "codex-remote-collab",
|
||||
"displayName": "%extension.displayName%",
|
||||
"description": "%extension.description%",
|
||||
"version": "0.4.0",
|
||||
"publisher": "local",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"l10n": "./l10n",
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onCommand:codexRemoteCollab.openWeb",
|
||||
"onCommand:codexRemoteCollab.start",
|
||||
"onCommand:codexRemoteCollab.stop",
|
||||
"onCommand:codexRemoteCollab.setThreadId",
|
||||
"onCommand:codexRemoteCollab.sendInput",
|
||||
"onCommand:codexRemoteCollab.setRelayToken",
|
||||
"onCommand:codexRemoteCollab.configureCloud",
|
||||
"onCommand:codexRemoteCollab.pairCloud",
|
||||
"onCommand:codexRemoteCollab.snapshot"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "codexRemoteCollab.openWeb",
|
||||
"title": "%command.openWeb%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.start",
|
||||
"title": "%command.start%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.stop",
|
||||
"title": "%command.stop%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.setThreadId",
|
||||
"title": "%command.setThreadId%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.sendInput",
|
||||
"title": "%command.sendInput%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.setRelayToken",
|
||||
"title": "%command.setRelayToken%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.configureCloud",
|
||||
"title": "%command.configureCloud%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.pairCloud",
|
||||
"title": "%command.pairCloud%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.snapshot",
|
||||
"title": "%command.snapshot%"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "%configuration.title%",
|
||||
"properties": {
|
||||
"codexRemoteCollab.localRelayUrl": {
|
||||
"type": "string",
|
||||
"default": "ws://127.0.0.1:8787/v1/connect",
|
||||
"description": "%configuration.localRelayUrl%"
|
||||
},
|
||||
"codexRemoteCollab.relayUrl": {
|
||||
"type": "string",
|
||||
"default": "ws://127.0.0.1:8787/v1/connect",
|
||||
"description": "%configuration.relayUrl%",
|
||||
"deprecationMessage": "%configuration.relayUrl.deprecation%"
|
||||
},
|
||||
"codexRemoteCollab.cloudRelayUrl": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.cloudRelayUrl%"
|
||||
},
|
||||
"codexRemoteCollab.aetherUrl": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.aetherUrl%"
|
||||
},
|
||||
"codexRemoteCollab.autoStart": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.autoStart%"
|
||||
},
|
||||
"codexRemoteCollab.autoStartLocalRelay": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.autoStartLocalRelay%"
|
||||
},
|
||||
"codexRemoteCollab.mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"attach",
|
||||
"spawn"
|
||||
],
|
||||
"default": "attach",
|
||||
"description": "%configuration.mode%",
|
||||
"deprecationMessage": "%configuration.mode.deprecation%"
|
||||
},
|
||||
"codexRemoteCollab.controlMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sync",
|
||||
"async"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"%configuration.controlMode.sync%",
|
||||
"%configuration.controlMode.async%"
|
||||
],
|
||||
"default": "sync",
|
||||
"description": "%configuration.controlMode%"
|
||||
},
|
||||
"codexRemoteCollab.threadId": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.threadId%"
|
||||
},
|
||||
"codexRemoteCollab.autoDiscoverThread": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.autoDiscoverThread%"
|
||||
},
|
||||
"codexRemoteCollab.followVscodeSession": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.followVscodeSession%"
|
||||
},
|
||||
"codexRemoteCollab.ipcSocketPath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.ipcSocketPath%"
|
||||
},
|
||||
"codexRemoteCollab.hostId": {
|
||||
"type": "string",
|
||||
"default": "local",
|
||||
"description": "%configuration.hostId%"
|
||||
},
|
||||
"codexRemoteCollab.ipcStrictVersions": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.ipcStrictVersions%"
|
||||
},
|
||||
"codexRemoteCollab.codexCommand": {
|
||||
"type": "string",
|
||||
"default": "codex",
|
||||
"description": "%configuration.codexCommand%"
|
||||
},
|
||||
"codexRemoteCollab.codexArgs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [
|
||||
"app-server",
|
||||
"--stdio"
|
||||
],
|
||||
"description": "%configuration.codexArgs%"
|
||||
},
|
||||
"codexRemoteCollab.defaultCwd": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.defaultCwd%"
|
||||
},
|
||||
"codexRemoteCollab.approvalTimeoutMs": {
|
||||
"type": "number",
|
||||
"default": 300000,
|
||||
"minimum": 1000,
|
||||
"description": "%configuration.approvalTimeoutMs%"
|
||||
},
|
||||
"codexRemoteCollab.allowHighRiskApprovals": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%configuration.allowHighRiskApprovals%"
|
||||
},
|
||||
"codexRemoteCollab.relayReconnect": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.relayReconnect%"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run build:web && npm run build",
|
||||
"build:web": "npm --prefix ../web run build",
|
||||
"build": "tsc -p tsconfig.json && node scripts/sync-local-relay.cjs",
|
||||
"compile": "npm run build",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"start:stdio": "node dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"extension.displayName": "Codex Remote Collaboration",
|
||||
"extension.description": "Synchronize the current VS Code Codex conversation or manage independent Codex conversations from a local browser and Aether cloud.",
|
||||
"command.openWeb": "Codex Remote: Open Local Web Console",
|
||||
"command.start": "Codex Remote: Start Bridge",
|
||||
"command.stop": "Codex Remote: Stop Bridge",
|
||||
"command.setThreadId": "Codex Remote: Set Existing Thread ID",
|
||||
"command.sendInput": "Codex Remote: Send Input",
|
||||
"command.setRelayToken": "Codex Remote: Set Local Relay Token",
|
||||
"command.configureCloud": "Codex Remote: Configure Aether Cloud Manually",
|
||||
"command.pairCloud": "Codex Remote: Pair with Aether",
|
||||
"command.snapshot": "Codex Remote: Show Snapshot",
|
||||
"configuration.title": "Codex Remote Collaboration",
|
||||
"configuration.localRelayUrl": "Loopback relay used by the local browser UI. It remains active when Aether cloud sync is enabled.",
|
||||
"configuration.relayUrl": "Legacy relay setting retained for compatibility. Use localRelayUrl and cloudRelayUrl for new installations.",
|
||||
"configuration.relayUrl.deprecation": "Use codexRemoteCollab.localRelayUrl for local access and codexRemoteCollab.cloudRelayUrl for Aether cloud access.",
|
||||
"configuration.cloudRelayUrl": "Optional Aether cloud relay WebSocket URL. The device credential is stored separately in VS Code SecretStorage.",
|
||||
"configuration.aetherUrl": "Aether server origin used by the one-time pairing flow.",
|
||||
"configuration.autoStart": "Start the bridge when the extension activates.",
|
||||
"configuration.autoStartLocalRelay": "Automatically host the bundled relay for loopback ws:// URLs.",
|
||||
"configuration.mode": "Attach to the existing official VS Code Codex session, or spawn a separate app-server for legacy use.",
|
||||
"configuration.mode.deprecation": "Use codexRemoteCollab.controlMode. attach maps to sync and spawn maps to async.",
|
||||
"configuration.controlMode": "Choose whether the web console follows the current VS Code Codex conversation or manages independent conversations.",
|
||||
"configuration.controlMode.sync": "Synchronize with the conversation currently shown in the official VS Code Codex panel.",
|
||||
"configuration.controlMode.async": "Run an independent Codex app-server and manage its conversations from the web console.",
|
||||
"configuration.threadId": "Existing VS Code Codex conversation ID to follow. Empty uses the most recent locally available session.",
|
||||
"configuration.autoDiscoverThread": "Discover a recent VS Code Codex conversation when no thread ID is configured.",
|
||||
"configuration.followVscodeSession": "Follow conversation changes in the attached official VS Code Codex panel.",
|
||||
"configuration.ipcSocketPath": "Optional official Codex IPC socket path. Empty uses CODEX_HOME/ipc/ipc.sock.",
|
||||
"configuration.hostId": "Codex host identifier used for existing-session discovery.",
|
||||
"configuration.ipcStrictVersions": "Reject unknown private IPC stream versions instead of applying them optimistically.",
|
||||
"configuration.codexCommand": "Asynchronous mode: Codex executable used to launch the independent app-server.",
|
||||
"configuration.codexArgs": "Asynchronous mode: arguments passed to the Codex executable.",
|
||||
"configuration.defaultCwd": "Asynchronous mode: working directory used when starting a conversation.",
|
||||
"configuration.approvalTimeoutMs": "Milliseconds before an unanswered Codex approval or input request is denied locally.",
|
||||
"configuration.allowHighRiskApprovals": "Allow the remote operator to approve high-risk commands. Keep disabled unless the relay and host are tightly controlled.",
|
||||
"configuration.relayReconnect": "Reconnect outbound relay WebSockets after a disconnect."
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"extension.displayName": "Codex 远程协同",
|
||||
"extension.description": "从本地浏览器或 Aether 云端同步 VS Code 当前 Codex 会话,或独立管理 Codex 会话。",
|
||||
"command.openWeb": "Codex 远程:打开本地 Web 控制台",
|
||||
"command.start": "Codex 远程:启动桥接",
|
||||
"command.stop": "Codex 远程:停止桥接",
|
||||
"command.setThreadId": "Codex 远程:设置现有会话 ID",
|
||||
"command.sendInput": "Codex 远程:发送输入",
|
||||
"command.setRelayToken": "Codex 远程:设置本地中继令牌",
|
||||
"command.configureCloud": "Codex 远程:手动配置 Aether 云端",
|
||||
"command.pairCloud": "Codex 远程:与 Aether 配对",
|
||||
"command.snapshot": "Codex 远程:显示会话快照",
|
||||
"configuration.title": "Codex 远程协同",
|
||||
"configuration.localRelayUrl": "本地浏览器控制台使用的回环中继地址。启用 Aether 云同步后仍保持连接。",
|
||||
"configuration.relayUrl": "为兼容旧版本保留的中继设置。新安装请使用 localRelayUrl 和 cloudRelayUrl。",
|
||||
"configuration.relayUrl.deprecation": "本地访问请使用 codexRemoteCollab.localRelayUrl,Aether 云端访问请使用 codexRemoteCollab.cloudRelayUrl。",
|
||||
"configuration.cloudRelayUrl": "可选的 Aether 云端 WebSocket 中继地址。设备凭据单独保存在 VS Code SecretStorage 中。",
|
||||
"configuration.aetherUrl": "一次性配对流程使用的 Aether 服务地址。",
|
||||
"configuration.autoStart": "扩展激活时自动启动桥接。",
|
||||
"configuration.autoStartLocalRelay": "为回环 ws:// 地址自动启动扩展内置的本地中继。",
|
||||
"configuration.mode": "附加到官方 VS Code Codex 现有会话,或为兼容旧版本启动独立 app-server。",
|
||||
"configuration.mode.deprecation": "请改用 codexRemoteCollab.controlMode。attach 对应 sync,spawn 对应 async。",
|
||||
"configuration.controlMode": "选择 Web 控制台是跟随 VS Code 当前 Codex 会话,还是独立管理会话。",
|
||||
"configuration.controlMode.sync": "同步展示官方 VS Code Codex 面板当前打开的会话。",
|
||||
"configuration.controlMode.async": "启动独立 Codex app-server,并从 Web 控制台管理其会话。",
|
||||
"configuration.threadId": "要跟随的现有 VS Code Codex 会话 ID。留空时使用本机最近可附加的会话。",
|
||||
"configuration.autoDiscoverThread": "未设置会话 ID 时自动发现最近的 VS Code Codex 会话。",
|
||||
"configuration.followVscodeSession": "自动跟随官方 VS Code Codex 面板中的会话切换。",
|
||||
"configuration.ipcSocketPath": "可选的官方 Codex IPC socket 路径。留空时使用 CODEX_HOME/ipc/ipc.sock。",
|
||||
"configuration.hostId": "现有会话发现使用的 Codex 主机标识。",
|
||||
"configuration.ipcStrictVersions": "拒绝未知的私有 IPC 流版本,不进行乐观兼容。",
|
||||
"configuration.codexCommand": "异步模式:用于启动独立 app-server 的 Codex 可执行文件。",
|
||||
"configuration.codexArgs": "异步模式:传给 Codex 可执行文件的参数。",
|
||||
"configuration.defaultCwd": "异步模式:启动会话时使用的工作目录。",
|
||||
"configuration.approvalTimeoutMs": "Codex 授权或输入请求无人处理时,在本地拒绝前等待的毫秒数。",
|
||||
"configuration.allowHighRiskApprovals": "允许远程操作员批准高风险命令。仅在中继和主机均受严格控制时启用。",
|
||||
"configuration.relayReconnect": "中继 WebSocket 断开后自动重连。"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const extensionRoot = path.resolve(__dirname, "..");
|
||||
const projectRoot = path.resolve(extensionRoot, "..");
|
||||
const outputRoot = path.join(extensionRoot, "dist", "local-relay");
|
||||
const publicRoot = path.join(extensionRoot, "dist", "public");
|
||||
const vuePublicRoot = path.join(projectRoot, "web", "dist");
|
||||
|
||||
if (!fs.existsSync(path.join(vuePublicRoot, "index.html"))) {
|
||||
throw new Error("web/dist is missing; run npm run build:web before building the extension");
|
||||
}
|
||||
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
fs.rmSync(publicRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
fs.mkdirSync(publicRoot, { recursive: true });
|
||||
fs.copyFileSync(path.join(projectRoot, "relay", "server.js"), path.join(outputRoot, "server.js"));
|
||||
fs.cpSync(vuePublicRoot, publicRoot, { recursive: true });
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CodexAgentAdapter, CodexAgentAdapterOptions } from "./codexAgentAdapter";
|
||||
import { RelayClient, RelayClientOptions } from "./relayClient";
|
||||
import { RelayHost, RelayHostOptions } from "./relayHost";
|
||||
import { AgentAdapter, Logger, RelayTransport } from "./protocol";
|
||||
|
||||
export interface CodexRemoteBridgeOptions {
|
||||
/** Use a supplied adapter/transport when embedding or testing. */
|
||||
adapter?: AgentAdapter;
|
||||
relay?: RelayTransport;
|
||||
adapterOptions?: CodexAgentAdapterOptions;
|
||||
relayOptions?: RelayClientOptions;
|
||||
sessionId?: string;
|
||||
capabilities?: Iterable<string>;
|
||||
logger?: Logger;
|
||||
}
|
||||
export interface CodexRemoteBridge {
|
||||
adapter: AgentAdapter;
|
||||
relay: RelayTransport;
|
||||
host: RelayHost;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Construct the default outbound VS Code bridge in one call. */
|
||||
export function createBridge(options: CodexRemoteBridgeOptions): CodexRemoteBridge {
|
||||
const adapter = options.adapter ?? new CodexAgentAdapter(options.adapterOptions);
|
||||
const relay = options.relay ?? (() => {
|
||||
if (!options.relayOptions) throw new Error("relayOptions are required when no relay transport is supplied");
|
||||
return new RelayClient(options.relayOptions);
|
||||
})();
|
||||
const hostOptions: RelayHostOptions = {
|
||||
adapter,
|
||||
relay,
|
||||
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options.logger ? { logger: options.logger } : {}),
|
||||
};
|
||||
const host = new RelayHost(hostOptions);
|
||||
return {
|
||||
adapter,
|
||||
relay,
|
||||
host,
|
||||
start: () => host.start(),
|
||||
stop: () => host.stop(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CodexAgentAdapter } from "./codexAgentAdapter";
|
||||
import { RelayHost } from "./relayHost";
|
||||
import { StdioRelayTransport } from "./relayClient";
|
||||
|
||||
/** Standalone bridge: relay frames in stdin, relay frames out on stdout. */
|
||||
async function main(): Promise<void> {
|
||||
const logger = {
|
||||
debug: (message: string, ...args: unknown[]) => console.error(`[debug] ${message}`, ...args),
|
||||
info: (message: string, ...args: unknown[]) => console.error(`[info] ${message}`, ...args),
|
||||
warn: (message: string, ...args: unknown[]) => console.error(`[warn] ${message}`, ...args),
|
||||
error: (message: string, ...args: unknown[]) => console.error(`[error] ${message}`, ...args),
|
||||
};
|
||||
const command = process.env.CODEX_COMMAND || "codex";
|
||||
const args = process.env.CODEX_APP_SERVER_ARGS ? JSON.parse(process.env.CODEX_APP_SERVER_ARGS) as string[] : ["app-server", "--stdio"];
|
||||
const adapter = new CodexAgentAdapter({ command, args, defaultCwd: process.env.CODEX_WORKSPACE, logger });
|
||||
const relay = new StdioRelayTransport(process.stdin, process.stdout, logger);
|
||||
const host = new RelayHost({ adapter, relay, sendHandshake: true, logger });
|
||||
const shutdown = async (): Promise<void> => {
|
||||
await host.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
await host.start();
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user