mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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/
|
||||
|
||||
+100
-73
@@ -1,19 +1,53 @@
|
||||
# ==================== 必须配置(启动前) ====================
|
||||
# 以下配置项必须在项目启动前设置
|
||||
|
||||
# 数据库配置
|
||||
# 应用端口(默认 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
|
||||
|
||||
# Compose 应用容器的非 root 数字身份。
|
||||
# install.sh 会自动写入安装用户的 UID/GID。
|
||||
AETHER_CONTAINER_UID=65532
|
||||
AETHER_CONTAINER_GID=65532
|
||||
|
||||
# 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,还要配合:
|
||||
# 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,82 +55,75 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# 支付回调共享密钥(公开 /api/payment/callback/* 入口必须携带 x-payment-callback-token)
|
||||
# 建议使用 32+ 位随机字符串
|
||||
PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-callback-secret
|
||||
# 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
|
||||
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
|
||||
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
|
||||
# 请求体完整读取总超时默认关闭;确需限制时配置 1000-600000 毫秒的非零值。
|
||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=0
|
||||
# 单请求解压后 Payload 上限(MiB),默认 256;显式设为 0 才表示不限制。
|
||||
# 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
|
||||
|
||||
# HTTP 连接池上限(默认总预算约 200,按 worker 平分)
|
||||
# 如果容器内存偏高,可继续下调;例如 2 worker 时设为 80-100
|
||||
# HTTP_MAX_CONNECTIONS=100
|
||||
# 可选 Provider OAuth 客户端。使用 Gemini CLI / Antigravity 浏览器授权时必须配置
|
||||
# 对应的 client secret;client ID 未配置时使用内置的公开 native-app client ID。
|
||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_ID=
|
||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# HTTP 保活连接数(默认约为 max_connections 的 30%)
|
||||
# HTTP_KEEPALIVE_CONNECTIONS=30
|
||||
|
||||
# HTTP 代理/Tunnel 客户端空闲清理(默认每 5 分钟扫描,空闲 600 秒即关闭)
|
||||
# HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES=5
|
||||
# HTTP_CLIENT_IDLE_CLEANUP_MAX_SECONDS=600
|
||||
|
||||
# curl_cffi session 池上限(默认 20,按 impersonate + proxy 组合缓存)
|
||||
# CURL_CFFI_MAX_SESSIONS=20
|
||||
|
||||
# 流式响应块缓存上限(单位 MB,默认 2)
|
||||
# 说明:
|
||||
# - 这是单个流式请求可保留的“解析后响应块”内存上限,不是全局上限
|
||||
# - 粗略峰值内存 ≈ 并发流数量 × RESPONSE_CHUNKS_MAX_SIZE_MB
|
||||
# 例如:100 并发、2MB 上限,理论峰值约 200MB
|
||||
# - 建议:
|
||||
# - 内存敏感环境:1
|
||||
# - 通用生产环境:2(默认)
|
||||
# - 需要更多调试上下文:4
|
||||
# RESPONSE_CHUNKS_MAX_SIZE_MB=2
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
# API_KEY_PREFIX=sk
|
||||
|
||||
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# CORS 配置(允许跨域的源,多个源用逗号分隔)
|
||||
# 示例: http://localhost:3000,https://example.com
|
||||
# 默认: * (允许所有源)
|
||||
# CORS_ORIGINS=*
|
||||
|
||||
# 启动预热配置(默认启用,降低首请求冷启动延迟)
|
||||
# 是否启用启动期预热任务(默认 true)
|
||||
# STARTUP_WARMUP_ENABLED=true
|
||||
# /readyz 是否等待预热完成(默认 true)
|
||||
# STARTUP_WARMUP_GATE_READINESS=true
|
||||
# 预热时优先 bootstrap 的 provider_type 列表(逗号分隔;留空表示自动探测)
|
||||
# STARTUP_WARMUP_PROVIDER_TYPES=codex,kiro
|
||||
|
||||
# ==================== 计费系统(可选) ====================
|
||||
# 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,91 +0,0 @@
|
||||
name: Build aether-hub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['hub-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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@v5
|
||||
|
||||
- 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@v5
|
||||
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@v5
|
||||
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
|
||||
@@ -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@v5
|
||||
|
||||
- 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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
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@v6
|
||||
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@v5
|
||||
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@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
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@v5
|
||||
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@v5
|
||||
|
||||
- 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@v5
|
||||
|
||||
- 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@v6
|
||||
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@v5
|
||||
|
||||
- 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@v6
|
||||
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@v6
|
||||
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,620 @@
|
||||
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
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos
|
||||
arch: amd64
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos
|
||||
arch: arm64
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
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 platform in linux macos; do
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p "${root}/bin" "${root}/frontend"
|
||||
|
||||
install -m 0755 \
|
||||
"artifacts/nightly-gateway-${platform}-${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
|
||||
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 4
|
||||
test "$(wc -l < release-assets/SHA256SUMS)" -eq 4
|
||||
(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
|
||||
|
||||
# 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
|
||||
aether-nightly-macos-amd64.tar.gz
|
||||
aether-nightly-macos-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,470 @@
|
||||
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
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos
|
||||
arch: amd64
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos
|
||||
arch: arm64
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
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 platform in linux macos; do
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p \
|
||||
"${root}/bin" \
|
||||
"${root}/frontend"
|
||||
|
||||
install -m 0755 "artifacts/aether-gateway-${platform}-${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
|
||||
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
|
||||
+12
-1
@@ -1,12 +1,17 @@
|
||||
# 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/
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
@@ -213,6 +218,10 @@ backups/
|
||||
|
||||
# Runtime lock files
|
||||
.locks/
|
||||
|
||||
# Local Rust/Cargo configuration
|
||||
.cargo/
|
||||
|
||||
# Demo and test files
|
||||
frontend/public/*-demo.html
|
||||
frontend/public/*-measure.html
|
||||
@@ -238,4 +247,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
+6612
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
-224
@@ -1,230 +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
|
||||
ARG GITHUB_TOKEN
|
||||
|
||||
# 运行时依赖(无 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 下载)
|
||||
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
|
||||
RUN set -eux; \
|
||||
auth_header=""; \
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then \
|
||||
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
|
||||
fi; \
|
||||
tag="${HUB_TAG:-}"; \
|
||||
if [ -z "$tag" ]; then \
|
||||
tag="$(curl -sL ${auth_header:+-H "$auth_header"} "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;' \
|
||||
' # 剥离 CF 头,防止泄露给上游 AI 提供商' \
|
||||
' proxy_set_header CF-Connecting-IP "";' \
|
||||
' proxy_set_header CF-IPCountry "";' \
|
||||
' proxy_set_header CF-Ray "";' \
|
||||
' proxy_set_header CF-Visitor "";' \
|
||||
' proxy_set_header CDN-Loop "";' \
|
||||
' proxy_set_header True-Client-IP "";' \
|
||||
' proxy_set_header CF-Worker "";' \
|
||||
' proxy_set_header CF-EW-Via "";' \
|
||||
' 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 --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 65532:65532
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
+136
-223
@@ -1,247 +1,160 @@
|
||||
# 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
|
||||
ARG GITHUB_TOKEN
|
||||
|
||||
# 运行时依赖(使用清华镜像源 + 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 下载)
|
||||
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
|
||||
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; \
|
||||
auth_header=""; \
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then \
|
||||
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
|
||||
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; \
|
||||
tag="${HUB_TAG:-}"; \
|
||||
if [ -z "$tag" ]; then \
|
||||
tag="$(curl -sL ${auth_header:+-H "$auth_header"} "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
|
||||
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;' \
|
||||
' # 剥离 CF 头,防止泄露给上游 AI 提供商' \
|
||||
' proxy_set_header CF-Connecting-IP "";' \
|
||||
' proxy_set_header CF-IPCountry "";' \
|
||||
' proxy_set_header CF-Ray "";' \
|
||||
' proxy_set_header CF-Visitor "";' \
|
||||
' proxy_set_header CDN-Loop "";' \
|
||||
' proxy_set_header True-Client-IP "";' \
|
||||
' proxy_set_header CF-Worker "";' \
|
||||
' proxy_set_header CF-EW-Via "";' \
|
||||
' 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 --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"]
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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"]
|
||||
|
||||
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>
|
||||
@@ -43,126 +44,193 @@ 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. 部署 / 更新(自动执行数据库迁移)
|
||||
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
|
||||
# Postgres + Redis (推荐)
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# 4. 升级前备份 (可选)
|
||||
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
# Single Node:同样使用 PostgreSQL + Redis,无需挂载本地数据库文件
|
||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
||||
```
|
||||
|
||||
### Docker Compose(本地构建镜像)
|
||||
应用镜像默认以固定非 root 身份 `65532:65532` 运行;Compose 移除全部 Linux capabilities、禁止提权、启用只读根文件系统,并提供带 `nosuid,nodev,noexec` 的 `/tmp`。如需使用其他身份,可在 `.env` 中设置非零的 `AETHER_CONTAINER_UID` / `AETHER_CONTAINER_GID`。数据库使用独立 PostgreSQL 容器和 named volume,不再需要调整应用数据库目录的权限。
|
||||
|
||||
|
||||
### 一键更新
|
||||
|
||||
Docker Compose 部署后,可在部署目录直接执行:
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
./update.sh
|
||||
```
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
|
||||
|
||||
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||
git pull
|
||||
```bash
|
||||
./update.sh --mode single-node
|
||||
```
|
||||
|
||||
现在仅支持 PostgreSQL。标准和单节点 Docker Compose 均部署 PostgreSQL + Redis;原生 systemd / launchd 安装需要显式提供 PostgreSQL `DATABASE_URL`,例如 `DATABASE_URL=postgresql://user:password@host:5432/aether`。旧数据库不会自动迁移或清空。升级时保留原有 PostgreSQL 密码、`JWT_SECRET_KEY` 和 `ENCRYPTION_KEY`,不要重新生成整个 `.env`。
|
||||
|
||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免非 root 用户被宿主机日志目录权限拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,额外挂载目录到 `/opt/aether/logs`,并让它归 `.env` 中配置的容器 UID/GID 所有;只读根文件系统不会阻止显式可写挂载。
|
||||
|
||||
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
|
||||
|
||||
正式 Release 还会发布由 GitHub Actions OIDC / Sigstore 签发的 SLSA build provenance。需要验证发布者身份时,下载目标 tarball 和 `AETHER_RELEASE_PROVENANCE.sigstore.json`,并把 `TAG` 设置为对应 Release tag:
|
||||
|
||||
```bash
|
||||
gh attestation verify "aether-${TAG}-linux-amd64.tar.gz" \
|
||||
--repo fawney19/Aether \
|
||||
--signer-workflow fawney19/Aether/.github/workflows/release.yml \
|
||||
--source-ref "refs/tags/${TAG}" \
|
||||
--bundle AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
```
|
||||
|
||||
`docker-compose.yml` 中的官方 PostgreSQL 和 Redis 镜像均固定到多架构 OCI index digest。升级这些依赖时应在发布变更中显式更新 digest,避免同名 tag 在无人审查的情况下改变部署内容。
|
||||
|
||||
正式发布到 GHCR 和 Docker Hub 的多架构 Aether 镜像也带有同一 GitHub Actions OIDC / Sigstore provenance;生产 `Dockerfile.app` 的 BusyBox 与 Distroless 基础镜像同样固定到多架构 OCI index digest。
|
||||
|
||||
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
||||
|
||||
标准和 Single Node Docker Compose 均使用 Docker named volume 存放 PostgreSQL 数据。
|
||||
|
||||
如果是本地源码构建镜像的部署,继续使用:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
./dev.sh
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev
|
||||
docker compose -f docker-compose.release-local.yml up -d --build
|
||||
```
|
||||
|
||||
## Aether Proxy (可选)
|
||||
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
|
||||
|
||||
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
|
||||
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
|
||||
|
||||
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release-local.yml down -v
|
||||
```
|
||||
|
||||
可选变量:
|
||||
|
||||
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
|
||||
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
||||
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
||||
|
||||
### 一键安装(PostgreSQL + Redis)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose
|
||||
```
|
||||
|
||||
原生 Linux systemd / macOS launchd 安装需先准备 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
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose --channel nightly
|
||||
```
|
||||
|
||||
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制部署请沿用已有 PostgreSQL 环境配置,并使用 `--mode single-node --channel nightly` 重新运行安装脚本升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
|
||||
|
||||
## 本地开发
|
||||
|
||||
依赖 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_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:可选的请求体完整读取超时;默认或显式设为 `0` 时关闭,非零值限制在 `1000-600000ms`
|
||||
- `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` | 初始管理员密码 |
|
||||
### S3 备份离线恢复
|
||||
|
||||
### 可选配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `APP_PORT` | 8084 | 应用端口 |
|
||||
| `API_KEY_PREFIX` | sk | API Key 前缀 |
|
||||
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
|
||||
| `GUNICORN_WORKERS` | 2 | 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 可能阻止物理删除。
|
||||
|
||||
---
|
||||
|
||||
@@ -180,4 +248,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.7"
|
||||
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,38 +0,0 @@
|
||||
# aether-hub
|
||||
|
||||
`aether-hub` 是 Tunnel Hub 服务,负责在 proxy 与 worker 之间路由帧。
|
||||
|
||||
已集成在Docker镜像中, 无需单独部署。
|
||||
|
||||
## 部署端指定 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 二进制。
|
||||
|
||||
## 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
|
||||
|
||||
## 运行时参数
|
||||
|
||||
- `TUNNEL_HUB_WORKER_IDLE_TIMEOUT`:worker 心跳空闲超时,默认 `60` 秒
|
||||
- `TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY`:单连接出站队列容量,默认 `128`;队列打满时会把连接视为拥塞并主动关闭,避免 Hub 内存无限增长
|
||||
|
||||
## 与部署脚本关系
|
||||
|
||||
- `./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,836 +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::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::Message;
|
||||
use dashmap::DashMap;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::protocol;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SendStatus {
|
||||
Queued,
|
||||
Closed,
|
||||
Congested,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection configuration (shared by proxy and worker handlers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ConnConfig {
|
||||
pub ping_interval: Duration,
|
||||
pub idle_timeout: Duration,
|
||||
pub outbound_queue_capacity: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bounded outbound channel with congestion-aware close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct BoundedOutbound {
|
||||
tx: mpsc::Sender<Message>,
|
||||
close_tx: watch::Sender<bool>,
|
||||
closing: AtomicBool,
|
||||
}
|
||||
|
||||
impl BoundedOutbound {
|
||||
pub fn new(tx: mpsc::Sender<Message>, close_tx: watch::Sender<bool>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
close_tx,
|
||||
closing: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, msg: Message) -> SendStatus {
|
||||
if self.is_closing() {
|
||||
return SendStatus::Closed;
|
||||
}
|
||||
|
||||
match self.tx.try_send(msg) {
|
||||
Ok(()) => SendStatus::Queued,
|
||||
Err(TrySendError::Closed(_)) => {
|
||||
self.mark_closing();
|
||||
SendStatus::Closed
|
||||
}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
self.mark_closing();
|
||||
SendStatus::Congested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_closing(&self) -> bool {
|
||||
self.closing.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Mark as closing. Returns `true` if this call was the first to flip the flag.
|
||||
pub fn mark_closing(&self) -> bool {
|
||||
if self.closing.swap(true, Ordering::AcqRel) {
|
||||
return false;
|
||||
}
|
||||
let _ = self.close_tx.send(true);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ProxyConn {
|
||||
pub id: u64,
|
||||
pub node_id: String,
|
||||
pub node_name: String,
|
||||
pub outbound: BoundedOutbound,
|
||||
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::Sender<Message>,
|
||||
close_tx: watch::Sender<bool>,
|
||||
max_streams: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
node_id,
|
||||
node_name,
|
||||
outbound: BoundedOutbound::new(tx, close_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 || !self.is_available() {
|
||||
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 is_available(&self) -> bool {
|
||||
!self.outbound.is_closing()
|
||||
}
|
||||
|
||||
pub fn request_close(&self) {
|
||||
self.outbound.mark_closing();
|
||||
}
|
||||
|
||||
pub fn send(&self, msg: Message) -> SendStatus {
|
||||
let was_closing = self.outbound.is_closing();
|
||||
let status = self.outbound.send(msg);
|
||||
if status == SendStatus::Congested && !was_closing {
|
||||
warn!(
|
||||
conn_id = self.id,
|
||||
node_id = %self.node_id,
|
||||
node_name = %self.node_name,
|
||||
queued_streams = self.stream_count.load(Ordering::Relaxed),
|
||||
"proxy outbound queue full, closing congested connection"
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct WorkerConn {
|
||||
pub id: u64,
|
||||
pub outbound: BoundedOutbound,
|
||||
}
|
||||
|
||||
impl WorkerConn {
|
||||
pub fn new(id: u64, tx: mpsc::Sender<Message>, close_tx: watch::Sender<bool>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
outbound: BoundedOutbound::new(tx, close_tx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_available(&self) -> bool {
|
||||
!self.outbound.is_closing()
|
||||
}
|
||||
|
||||
pub fn request_close(&self) {
|
||||
self.outbound.mark_closing();
|
||||
}
|
||||
|
||||
pub fn send(&self, msg: Message) -> SendStatus {
|
||||
let was_closing = self.outbound.is_closing();
|
||||
let status = self.outbound.send(msg);
|
||||
if status == SendStatus::Congested && !was_closing {
|
||||
warn!(
|
||||
worker_id = self.id,
|
||||
"worker outbound queue full, closing congested connection"
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()
|
||||
.filter(|c| c.is_available())
|
||||
.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,
|
||||
},
|
||||
);
|
||||
|
||||
match proxy_conn.send(Message::Binary(rebuilt_frame.into())) {
|
||||
SendStatus::Queued => {}
|
||||
SendStatus::Closed | SendStatus::Congested => {
|
||||
// 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 congested".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()
|
||||
.filter_map(|e| {
|
||||
let worker = e.value().clone();
|
||||
worker.is_available().then_some(worker)
|
||||
})
|
||||
.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 matches!(entry.value().send(msg.clone()), SendStatus::Queued) {
|
||||
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,169 +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::{ConnConfig, 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 (0 to disable)
|
||||
#[arg(long, default_value_t = 0, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
|
||||
proxy_idle_timeout: u64,
|
||||
|
||||
/// Worker-side idle timeout in seconds (0 to disable)
|
||||
#[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,
|
||||
|
||||
/// Per-connection outbound queue capacity before treating the socket as congested
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 128,
|
||||
env = "TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY"
|
||||
)]
|
||||
outbound_queue_capacity: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
hub: Arc<HubRouter>,
|
||||
proxy_conn_cfg: ConnConfig,
|
||||
worker_conn_cfg: ConnConfig,
|
||||
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 outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
|
||||
let ping_interval = Duration::from_secs(args.ping_interval);
|
||||
let state = AppState {
|
||||
hub,
|
||||
proxy_conn_cfg: ConnConfig {
|
||||
ping_interval,
|
||||
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
|
||||
outbound_queue_capacity,
|
||||
},
|
||||
worker_conn_cfg: ConnConfig {
|
||||
ping_interval,
|
||||
idle_timeout: Duration::from_secs(args.worker_idle_timeout),
|
||||
outbound_queue_capacity,
|
||||
},
|
||||
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.proxy_conn_cfg,
|
||||
)
|
||||
})
|
||||
.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.worker_conn_cfg)
|
||||
})
|
||||
}
|
||||
@@ -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,156 +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, watch};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
|
||||
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,
|
||||
cfg: ConnConfig,
|
||||
) {
|
||||
let conn_id = hub.alloc_conn_id();
|
||||
let (mut ws_tx, ws_rx) = ws.split();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<Message>(cfg.outbound_queue_capacity);
|
||||
let (close_tx, mut close_rx) = watch::channel(false);
|
||||
|
||||
let conn = Arc::new(ProxyConn::new(
|
||||
conn_id,
|
||||
node_id.clone(),
|
||||
node_name.clone(),
|
||||
tx,
|
||||
close_tx,
|
||||
max_streams,
|
||||
));
|
||||
|
||||
hub.register_proxy(conn.clone());
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => match msg {
|
||||
Some(msg) => {
|
||||
if ws_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
changed = close_rx.changed() => {
|
||||
if changed.is_err() || *close_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
});
|
||||
|
||||
let ping_conn = conn.clone();
|
||||
let ping_interval = cfg.ping_interval;
|
||||
let ping_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(ping_interval).await;
|
||||
let ping = protocol::encode_ping();
|
||||
if !matches!(
|
||||
ping_conn.send(Message::Binary(ping.into())),
|
||||
SendStatus::Queued
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reader_hub = hub.clone();
|
||||
let reader_conn = conn.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout).await;
|
||||
});
|
||||
|
||||
let _ = reader.await;
|
||||
ping_task.abort();
|
||||
conn.request_close();
|
||||
hub.unregister_proxy(conn_id, &node_id);
|
||||
drop(conn);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
writer.abort();
|
||||
let _ = writer.await;
|
||||
}
|
||||
|
||||
async fn run_proxy_reader(
|
||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
||||
hub: Arc<HubRouter>,
|
||||
conn: Arc<ProxyConn>,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
let idle_enabled = !idle_timeout.is_zero();
|
||||
let mut oversized_count = 0u32;
|
||||
loop {
|
||||
let msg = if idle_enabled {
|
||||
tokio::select! {
|
||||
msg = ws_rx.next() => msg,
|
||||
_ = tokio::time::sleep(idle_timeout) => {
|
||||
warn!(conn_id = conn.id, node_id = %conn.node_id, "proxy idle timeout");
|
||||
let _ = conn.send(Message::Binary(protocol::encode_goaway().into()));
|
||||
conn.request_close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ws_rx.next().await
|
||||
};
|
||||
|
||||
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");
|
||||
conn.request_close();
|
||||
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 = %conn.node_id, "proxy WebSocket closed");
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!(conn_id = conn.id, error = %e, "proxy WebSocket error");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +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::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::hub::{ConnConfig, HubRouter, SendStatus, WorkerConn};
|
||||
use crate::protocol;
|
||||
|
||||
pub async fn handle_worker_connection(ws: WebSocket, hub: Arc<HubRouter>, cfg: ConnConfig) {
|
||||
let conn_id = hub.alloc_conn_id();
|
||||
let (mut ws_tx, ws_rx) = ws.split();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<Message>(cfg.outbound_queue_capacity);
|
||||
let (close_tx, mut close_rx) = watch::channel(false);
|
||||
|
||||
let conn = Arc::new(WorkerConn::new(conn_id, tx, close_tx));
|
||||
hub.register_worker(conn.clone());
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => match msg {
|
||||
Some(msg) => {
|
||||
if ws_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
changed = close_rx.changed() => {
|
||||
if changed.is_err() || *close_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
});
|
||||
|
||||
let liveness_clock = Instant::now();
|
||||
let last_seen_ms = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let reader_hub = hub.clone();
|
||||
let reader_conn = conn.clone();
|
||||
let reader_last_seen_ms = last_seen_ms.clone();
|
||||
let liveness_conn = conn.clone();
|
||||
let mut reader = tokio::spawn(async move {
|
||||
run_worker_reader(
|
||||
ws_rx,
|
||||
reader_hub,
|
||||
conn_id,
|
||||
reader_conn,
|
||||
reader_last_seen_ms,
|
||||
liveness_clock,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let liveness_last_seen_ms = last_seen_ms.clone();
|
||||
let mut liveness = tokio::spawn(async move {
|
||||
run_worker_liveness(
|
||||
conn_id,
|
||||
liveness_conn,
|
||||
cfg.ping_interval,
|
||||
cfg.idle_timeout,
|
||||
liveness_last_seen_ms,
|
||||
liveness_clock,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let reader_finished = tokio::select! {
|
||||
res = &mut reader => {
|
||||
if let Err(err) = res {
|
||||
warn!(worker_id = conn_id, error = %err, "worker reader task failed");
|
||||
}
|
||||
true
|
||||
}
|
||||
res = &mut liveness => {
|
||||
if let Err(err) = res {
|
||||
warn!(worker_id = conn_id, error = %err, "worker liveness task failed");
|
||||
}
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
conn.request_close();
|
||||
if !reader_finished {
|
||||
reader.abort();
|
||||
let _ = reader.await;
|
||||
}
|
||||
if reader_finished {
|
||||
liveness.abort();
|
||||
let _ = liveness.await;
|
||||
}
|
||||
|
||||
hub.unregister_worker(conn_id);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
writer.abort();
|
||||
let _ = writer.await;
|
||||
}
|
||||
|
||||
async fn run_worker_reader(
|
||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
||||
hub: Arc<HubRouter>,
|
||||
conn_id: u64,
|
||||
conn: Arc<WorkerConn>,
|
||||
last_seen_ms: Arc<AtomicU64>,
|
||||
liveness_clock: Instant,
|
||||
) {
|
||||
loop {
|
||||
match ws_rx.next().await {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
last_seen_ms.store(elapsed_millis(liveness_clock), Ordering::Relaxed);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
if header.msg_type == protocol::HEARTBEAT_ACK {
|
||||
hub.handle_worker_heartbeat_ack(&mut data);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(err_msg) = hub.handle_worker_frame(conn_id, &mut data) {
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_worker_liveness(
|
||||
conn_id: u64,
|
||||
conn: Arc<WorkerConn>,
|
||||
ping_interval: Duration,
|
||||
idle_timeout: Duration,
|
||||
last_seen_ms: Arc<AtomicU64>,
|
||||
liveness_clock: Instant,
|
||||
) {
|
||||
let ping_interval_ms = ping_interval.as_millis().max(1) as u64;
|
||||
let idle_timeout_ms = idle_timeout.as_millis() as u64;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(ping_interval).await;
|
||||
|
||||
let ping = protocol::encode_ping();
|
||||
if !matches!(conn.send(Message::Binary(ping.into())), SendStatus::Queued) {
|
||||
break;
|
||||
}
|
||||
|
||||
if idle_timeout.is_zero() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let now_ms = elapsed_millis(liveness_clock);
|
||||
let last_seen = last_seen_ms.load(Ordering::Relaxed);
|
||||
let silent_for_ms = now_ms.saturating_sub(last_seen);
|
||||
if silent_for_ms < idle_timeout_ms {
|
||||
continue;
|
||||
}
|
||||
|
||||
let missed_heartbeats = (silent_for_ms / ping_interval_ms).max(1);
|
||||
warn!(
|
||||
worker_id = conn_id,
|
||||
idle_timeout_secs = idle_timeout.as_secs(),
|
||||
silent_for_ms = silent_for_ms,
|
||||
missed_heartbeats = missed_heartbeats,
|
||||
"worker heartbeat timeout"
|
||||
);
|
||||
let _ = conn.send(Message::Binary(protocol::encode_goaway().into()));
|
||||
conn.request_close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_millis(started_at: Instant) -> u64 {
|
||||
started_at.elapsed().as_millis().min(u64::MAX as u128) as u64
|
||||
}
|
||||
@@ -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
-3403
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
[package]
|
||||
name = "aether-proxy"
|
||||
version = "0.2.4"
|
||||
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"] }
|
||||
hyper = { version = "1", features = ["client", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1", "http2", "tokio"] }
|
||||
http-body-util = "0.1"
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
tokio-rustls = "0.26"
|
||||
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"] }
|
||||
tower-service = "0.3"
|
||||
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.4/aether-proxy-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/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.4/aether-proxy-macos-amd64.tar.gz) |
|
||||
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-macos-arm64.tar.gz) |
|
||||
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/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,360 +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::state::{AppState, ProxyMetrics, ServerContext};
|
||||
use crate::upstream_client;
|
||||
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 Hyper client for tunnel upstream requests (shared).
|
||||
// DNS still flows through validated addresses from DnsCache, while the
|
||||
// custom connector exposes per-request connect/TLS timing when available.
|
||||
let upstream_client = upstream_client::build_upstream_client(&config, Arc::clone(&dns_cache));
|
||||
|
||||
// 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,
|
||||
upstream_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 setup;
|
||||
mod state;
|
||||
mod target_filter;
|
||||
mod tunnel;
|
||||
mod upstream_client;
|
||||
|
||||
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,77 +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;
|
||||
use crate::upstream_client::UpstreamClient;
|
||||
|
||||
/// 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>,
|
||||
/// Hyper client for tunnel upstream requests with validated DNS and connection timing.
|
||||
pub upstream_client: UpstreamClient,
|
||||
/// 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,415 +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 http_body_util::BodyExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::state::{AppState, ServerContext};
|
||||
use crate::target_filter;
|
||||
use crate::upstream_client::{self, UpstreamRequestBody};
|
||||
|
||||
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).
|
||||
///
|
||||
/// `host` and `content-length` are managed by the HTTP client (reqwest/hyper):
|
||||
/// - `host` → translated to `:authority` pseudo-header in HTTP/2; forwarding
|
||||
/// the original `host` alongside `:authority` triggers PROTOCOL_ERROR on
|
||||
/// strict H2 implementations (e.g. Google APIs).
|
||||
/// - `content-length` → recalculated by hyper from the actual body; a stale
|
||||
/// value from the tunnel (body may have been re-compressed) causes H2
|
||||
/// PROTOCOL_ERROR when it mismatches the real frame length.
|
||||
const BLOCKED_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"content-length",
|
||||
"host",
|
||||
"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.upstream_client;
|
||||
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
|
||||
|
||||
let method: hyper::Method = meta.method.parse().unwrap_or(hyper::Method::GET);
|
||||
let mut request = match hyper::Request::builder()
|
||||
.method(method)
|
||||
.uri(meta.url.as_str())
|
||||
.body(UpstreamRequestBody::new(body.clone()))
|
||||
{
|
||||
Ok(request) => request,
|
||||
Err(e) => {
|
||||
send_error(
|
||||
frame_tx,
|
||||
stream_id,
|
||||
&format!("invalid upstream request: {e}"),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let headers = request.headers_mut();
|
||||
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)) = (
|
||||
hyper::header::HeaderName::from_bytes(k.as_bytes()),
|
||||
hyper::header::HeaderValue::from_str(v),
|
||||
) {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
let body_size = body.len();
|
||||
let mut captured_connection = upstream_client::capture_connection(&mut request);
|
||||
let connection_start = Instant::now();
|
||||
let connection_capture = tokio::spawn(async move {
|
||||
let connected = captured_connection.wait_for_connection_metadata().await;
|
||||
connected
|
||||
.as_ref()
|
||||
.map(|_| connection_start.elapsed().as_millis() as u64)
|
||||
});
|
||||
|
||||
let upstream_start = Instant::now();
|
||||
let response = match tokio::time::timeout(timeout, client.request(request)).await {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(e)) => {
|
||||
connection_capture.abort();
|
||||
server
|
||||
.metrics
|
||||
.failed_requests
|
||||
.fetch_add(1, Ordering::Release);
|
||||
let msg = if e.is_connect() {
|
||||
format!("upstream connect error: {e}")
|
||||
} else {
|
||||
format!("upstream error: {e}")
|
||||
};
|
||||
send_error(frame_tx, stream_id, &msg).await;
|
||||
return None;
|
||||
}
|
||||
Err(_) => {
|
||||
connection_capture.abort();
|
||||
server
|
||||
.metrics
|
||||
.failed_requests
|
||||
.fetch_add(1, Ordering::Release);
|
||||
send_error(frame_tx, stream_id, "upstream timeout").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;
|
||||
// Short timeout: on connection reuse hyper may never fire the connect
|
||||
// callback, so avoid blocking indefinitely.
|
||||
let connection_acquire_ms =
|
||||
match tokio::time::timeout(Duration::from_millis(100), connection_capture).await {
|
||||
Ok(Ok(ms)) => ms,
|
||||
Ok(Err(_)) => None, // JoinError (task panicked / cancelled)
|
||||
Err(_) => None, // timeout -- task is detached but lightweight
|
||||
};
|
||||
let request_timing =
|
||||
upstream_client::resolve_request_timing(&response, connection_acquire_ms, ttfb_ms);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
let timing = serde_json::json!({
|
||||
"dns_ms": dns_ms,
|
||||
"connection_acquire_ms": request_timing.connection_acquire_ms,
|
||||
"connection_reused": request_timing.connection_reused,
|
||||
"connect_ms": request_timing.connect_ms,
|
||||
"tls_ms": request_timing.tls_ms,
|
||||
"ttfb_ms": ttfb_ms,
|
||||
"upstream_ms": ttfb_ms,
|
||||
"response_wait_ms": request_timing.response_wait_ms,
|
||||
"upstream_processing_ms": request_timing.response_wait_ms,
|
||||
"timing_source": "instrumented_connector",
|
||||
"total_ms": connect_elapsed.as_millis() as u64,
|
||||
"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.into_body().into_data_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)
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::Full;
|
||||
use hyper::rt;
|
||||
use hyper::Response;
|
||||
use hyper::Uri;
|
||||
pub use hyper_util::client::legacy::connect::capture_connection;
|
||||
use hyper_util::client::legacy::connect::dns::Name;
|
||||
use hyper_util::client::legacy::connect::{Connected, Connection, HttpConnector};
|
||||
use hyper_util::client::legacy::Client;
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
|
||||
use rustls::pki_types::ServerName;
|
||||
use rustls::ClientConfig;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tower_service::Service;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::target_filter::{self, DnsCache};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
type PlainStream = TokioIo<TcpStream>;
|
||||
type TlsStream = TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
|
||||
|
||||
pub type UpstreamRequestBody = Full<Bytes>;
|
||||
pub type UpstreamClient = Client<InstrumentedConnector, UpstreamRequestBody>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ConnectTiming {
|
||||
pub connect_ms: u64,
|
||||
pub tls_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct RequestTiming {
|
||||
pub connection_acquire_ms: u64,
|
||||
pub connect_ms: u64,
|
||||
pub tls_ms: u64,
|
||||
pub response_wait_ms: u64,
|
||||
pub connection_reused: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ValidatedResolver {
|
||||
dns_cache: Arc<DnsCache>,
|
||||
}
|
||||
|
||||
impl ValidatedResolver {
|
||||
pub fn new(dns_cache: Arc<DnsCache>) -> Self {
|
||||
Self { dns_cache }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ValidatedAddrs {
|
||||
inner: std::vec::IntoIter<std::net::SocketAddr>,
|
||||
}
|
||||
|
||||
impl Iterator for ValidatedAddrs {
|
||||
type Item = std::net::SocketAddr;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl Service<Name> for ValidatedResolver {
|
||||
type Response = ValidatedAddrs;
|
||||
type Error = io::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, name: Name) -> Self::Future {
|
||||
let dns_cache = Arc::clone(&self.dns_cache);
|
||||
let host = name.as_str().to_string();
|
||||
Box::pin(async move {
|
||||
if let Some(addrs) = dns_cache.get_by_host(&host).await {
|
||||
return Ok(ValidatedAddrs {
|
||||
inner: (*addrs).clone().into_iter(),
|
||||
});
|
||||
}
|
||||
|
||||
let resolved = target_filter::resolve_public_addrs(&host, 0, dns_cache.as_ref())
|
||||
.await
|
||||
.map_err(|err| io::Error::other(err.to_string()))?;
|
||||
Ok(ValidatedAddrs {
|
||||
inner: resolved.into_iter(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InstrumentedConnector {
|
||||
http: HttpConnector<ValidatedResolver>,
|
||||
tls_config: Arc<ClientConfig>,
|
||||
}
|
||||
|
||||
impl Service<Uri> for InstrumentedConnector {
|
||||
type Response = TimedConn;
|
||||
type Error = BoxError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.http.poll_ready(cx).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn call(&mut self, dst: Uri) -> Self::Future {
|
||||
let scheme = dst.scheme_str().map(|value| value.to_ascii_lowercase());
|
||||
let tls_config = Arc::clone(&self.tls_config);
|
||||
let connecting = self.http.call(dst.clone());
|
||||
let connect_start = std::time::Instant::now();
|
||||
|
||||
Box::pin(async move {
|
||||
match scheme.as_deref() {
|
||||
Some("http") => {
|
||||
let tcp = connecting.await.map_err(|err| Box::new(err) as BoxError)?;
|
||||
let connect_ms = connect_start.elapsed().as_millis() as u64;
|
||||
Ok(TimedConn::new(
|
||||
MaybeHttpsStream::Http(tcp),
|
||||
ConnectTiming {
|
||||
connect_ms,
|
||||
tls_ms: 0,
|
||||
},
|
||||
))
|
||||
}
|
||||
Some("https") => {
|
||||
let server_name = resolve_server_name(&dst)?;
|
||||
let tcp = connecting.await.map_err(|err| Box::new(err) as BoxError)?;
|
||||
let connect_ms = connect_start.elapsed().as_millis() as u64;
|
||||
|
||||
let tls_start = std::time::Instant::now();
|
||||
let tls_stream = TlsConnector::from(tls_config)
|
||||
.connect(server_name, tcp.into_inner())
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
let tls_ms = tls_start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(TimedConn::new(
|
||||
MaybeHttpsStream::Https(TokioIo::new(tls_stream)),
|
||||
ConnectTiming { connect_ms, tls_ms },
|
||||
))
|
||||
}
|
||||
Some(other) => Err(io::Error::other(format!("unsupported scheme {other}")).into()),
|
||||
None => Err(io::Error::other("missing scheme").into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_upstream_client(config: &Config, dns_cache: Arc<DnsCache>) -> UpstreamClient {
|
||||
let mut http = HttpConnector::new_with_resolver(ValidatedResolver::new(dns_cache));
|
||||
http.enforce_http(false);
|
||||
http.set_connect_timeout(Some(Duration::from_secs(
|
||||
config.upstream_connect_timeout_secs,
|
||||
)));
|
||||
http.set_nodelay(config.upstream_tcp_nodelay);
|
||||
if config.upstream_tcp_keepalive_secs > 0 {
|
||||
http.set_keepalive(Some(Duration::from_secs(
|
||||
config.upstream_tcp_keepalive_secs,
|
||||
)));
|
||||
} else {
|
||||
http.set_keepalive(None);
|
||||
}
|
||||
|
||||
let connector = InstrumentedConnector {
|
||||
http,
|
||||
tls_config: build_tls_config(),
|
||||
};
|
||||
|
||||
let mut builder = Client::builder(TokioExecutor::new());
|
||||
builder.pool_max_idle_per_host(config.upstream_pool_max_idle_per_host);
|
||||
builder.pool_idle_timeout(Duration::from_secs(config.upstream_pool_idle_timeout_secs));
|
||||
builder.pool_timer(TokioTimer::new());
|
||||
builder.build(connector)
|
||||
}
|
||||
|
||||
pub fn resolve_request_timing<B>(
|
||||
response: &Response<B>,
|
||||
connection_acquire_ms: Option<u64>,
|
||||
ttfb_ms: u64,
|
||||
) -> RequestTiming {
|
||||
let raw = response
|
||||
.extensions()
|
||||
.get::<ConnectTiming>()
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
|
||||
let raw_connection_ms = raw.connect_ms.saturating_add(raw.tls_ms);
|
||||
let measured_acquire_ms = connection_acquire_ms.unwrap_or(raw_connection_ms.min(ttfb_ms));
|
||||
let likely_reused = measured_acquire_ms <= 5 && raw_connection_ms > 0;
|
||||
let connector_matches_request = raw_connection_ms <= measured_acquire_ms.saturating_add(25);
|
||||
|
||||
let (connect_ms, tls_ms) = if likely_reused || !connector_matches_request {
|
||||
(0, 0)
|
||||
} else {
|
||||
(raw.connect_ms, raw.tls_ms)
|
||||
};
|
||||
|
||||
RequestTiming {
|
||||
connection_acquire_ms: measured_acquire_ms,
|
||||
connect_ms,
|
||||
tls_ms,
|
||||
response_wait_ms: ttfb_ms.saturating_sub(measured_acquire_ms),
|
||||
connection_reused: likely_reused,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tls_config() -> Arc<ClientConfig> {
|
||||
let root_store =
|
||||
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let mut config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
fn resolve_server_name(uri: &Uri) -> Result<ServerName<'static>, BoxError> {
|
||||
let host = uri.host().ok_or_else(|| io::Error::other("missing host"))?;
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return Ok(ServerName::from(ip));
|
||||
}
|
||||
|
||||
Ok(ServerName::try_from(host.to_string())?)
|
||||
}
|
||||
|
||||
pub struct TimedConn {
|
||||
inner: MaybeHttpsStream,
|
||||
timing: ConnectTiming,
|
||||
}
|
||||
|
||||
impl TimedConn {
|
||||
fn new(inner: MaybeHttpsStream, timing: ConnectTiming) -> Self {
|
||||
Self { inner, timing }
|
||||
}
|
||||
}
|
||||
|
||||
impl Connection for TimedConn {
|
||||
fn connected(&self) -> Connected {
|
||||
self.inner.connected().extra(self.timing)
|
||||
}
|
||||
}
|
||||
|
||||
impl rt::Read for TimedConn {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: rt::ReadBufCursor<'_>,
|
||||
) -> Poll<Result<(), io::Error>> {
|
||||
Pin::new(&mut self.inner).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl rt::Write for TimedConn {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<Result<usize, io::Error>> {
|
||||
Pin::new(&mut self.inner).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<(), io::Error>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
|
||||
fn is_write_vectored(&self) -> bool {
|
||||
self.inner.is_write_vectored()
|
||||
}
|
||||
|
||||
fn poll_write_vectored(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
bufs: &[std::io::IoSlice<'_>],
|
||||
) -> Poll<Result<usize, io::Error>> {
|
||||
Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum MaybeHttpsStream {
|
||||
Http(PlainStream),
|
||||
Https(TlsStream),
|
||||
}
|
||||
|
||||
impl Connection for MaybeHttpsStream {
|
||||
fn connected(&self) -> Connected {
|
||||
match self {
|
||||
Self::Http(stream) => stream.connected(),
|
||||
Self::Https(stream) => {
|
||||
let (tcp, tls) = stream.inner().get_ref();
|
||||
if tls.alpn_protocol() == Some(b"h2") {
|
||||
tcp.connected().negotiated_h2()
|
||||
} else {
|
||||
tcp.connected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rt::Read for MaybeHttpsStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: rt::ReadBufCursor<'_>,
|
||||
) -> Poll<Result<(), io::Error>> {
|
||||
match Pin::get_mut(self) {
|
||||
Self::Http(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||
Self::Https(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rt::Write for MaybeHttpsStream {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<Result<usize, io::Error>> {
|
||||
match Pin::get_mut(self) {
|
||||
Self::Http(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||
Self::Https(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
|
||||
match Pin::get_mut(self) {
|
||||
Self::Http(stream) => Pin::new(stream).poll_flush(cx),
|
||||
Self::Https(stream) => Pin::new(stream).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
|
||||
match Pin::get_mut(self) {
|
||||
Self::Http(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||
Self::Https(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_write_vectored(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(stream) => stream.is_write_vectored(),
|
||||
Self::Https(stream) => stream.is_write_vectored(),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_write_vectored(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
bufs: &[std::io::IoSlice<'_>],
|
||||
) -> Poll<Result<usize, io::Error>> {
|
||||
match Pin::get_mut(self) {
|
||||
Self::Http(stream) => Pin::new(stream).poll_write_vectored(cx, bufs),
|
||||
Self::Https(stream) => Pin::new(stream).poll_write_vectored(cx, bufs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::Response;
|
||||
|
||||
#[test]
|
||||
fn fresh_connection_uses_connector_breakdown() {
|
||||
let mut response = Response::new(());
|
||||
response.extensions_mut().insert(ConnectTiming {
|
||||
connect_ms: 80,
|
||||
tls_ms: 40,
|
||||
});
|
||||
|
||||
let timing = resolve_request_timing(&response, Some(125), 600);
|
||||
|
||||
assert_eq!(timing.connection_acquire_ms, 125);
|
||||
assert_eq!(timing.connect_ms, 80);
|
||||
assert_eq!(timing.tls_ms, 40);
|
||||
assert_eq!(timing.response_wait_ms, 475);
|
||||
assert!(!timing.connection_reused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_connection_zeroes_stale_connect_timings() {
|
||||
let mut response = Response::new(());
|
||||
response.extensions_mut().insert(ConnectTiming {
|
||||
connect_ms: 70,
|
||||
tls_ms: 30,
|
||||
});
|
||||
|
||||
let timing = resolve_request_timing(&response, Some(0), 310);
|
||||
|
||||
assert_eq!(timing.connection_acquire_ms, 0);
|
||||
assert_eq!(timing.connect_ms, 0);
|
||||
assert_eq!(timing.tls_ms, 0);
|
||||
assert_eq!(timing.response_wait_ms, 310);
|
||||
assert!(timing.connection_reused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_connector_timings_when_capture_missing() {
|
||||
let mut response = Response::new(());
|
||||
response.extensions_mut().insert(ConnectTiming {
|
||||
connect_ms: 55,
|
||||
tls_ms: 25,
|
||||
});
|
||||
|
||||
let timing = resolve_request_timing(&response, None, 400);
|
||||
|
||||
assert_eq!(timing.connection_acquire_ms, 80);
|
||||
assert_eq!(timing.connect_ms, 55);
|
||||
assert_eq!(timing.tls_ms, 25);
|
||||
assert_eq!(timing.response_wait_ms, 320);
|
||||
assert!(!timing.connection_reused);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user