Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f85c0575e | ||
|
|
cf2b3db20d | ||
|
|
a9c6430531 | ||
|
|
8a580a66be | ||
|
|
8ae5e11021 | ||
|
|
fac04adc6f | ||
|
|
d8f1d8a3e2 | ||
|
|
abcbf3fddc | ||
|
|
e5a4d51c86 | ||
|
|
97e615fc21 | ||
|
|
e990134b70 | ||
|
|
c2af8a2377 | ||
|
|
c9609259db | ||
|
|
3cddd2b9f8 | ||
|
|
8879f8dd80 | ||
|
|
4aca5050b9 | ||
|
|
89919f7a89 | ||
|
|
e2da9ac03d | ||
|
|
e8d9eb6916 | ||
|
|
8865509011 | ||
|
|
bcda88710e | ||
|
|
d6b0e88dda | ||
|
|
1021e29c98 | ||
|
|
96082e32b7 | ||
|
|
f70b4f74b1 | ||
|
|
ebb93ada94 | ||
|
|
597d080d71 | ||
|
|
e4f90b3480 | ||
|
|
8c4d2db192 | ||
|
|
152eb7d454 | ||
|
|
8e5d581b9b | ||
|
|
99990c7a0d | ||
|
|
ffd61b961d | ||
|
|
dcf216ca09 | ||
|
|
6bf2bf51f2 | ||
|
|
44b0a8ff83 | ||
|
|
d3f8c993b1 | ||
|
|
535e43b527 | ||
|
|
2df67e32da | ||
|
|
6b85567bc6 | ||
|
|
2e25831b4d | ||
|
|
021af793fc | ||
|
|
7ba14f7433 | ||
|
|
40f5a6ab7c | ||
|
|
48c31b4825 | ||
|
|
520007c8d1 | ||
|
|
c2b6a6e1c0 | ||
|
|
f5255d6f76 | ||
|
|
7d6eac6558 | ||
|
|
6732397061 | ||
|
|
2020024b68 | ||
|
|
499e973db9 | ||
|
|
e3cfc090d3 | ||
|
|
38dcff2225 | ||
|
|
a1109b9354 | ||
|
|
2a57ddb02e | ||
|
|
847945cfdd | ||
|
|
ab88955111 | ||
|
|
fc5701f243 | ||
|
|
256b455315 | ||
|
|
2733f2e3c5 | ||
|
|
96bd5fb1c2 | ||
|
|
123148e277 | ||
|
|
d7edae99c5 | ||
|
|
51fe6c9519 | ||
|
|
e0161114e7 | ||
|
|
2872faab98 | ||
|
|
1d1e80fbf0 | ||
|
|
3d7c11a8b6 | ||
|
|
72264f0c53 | ||
|
|
d6d6c052aa | ||
|
|
901ce4e08e | ||
|
|
5fdc585212 | ||
|
|
0aa330561b | ||
|
|
1be1b029a0 | ||
|
|
cafafbd540 | ||
|
|
5ae19042f1 | ||
|
|
90969bd90b | ||
|
|
c53781d16c | ||
|
|
9842705eb9 | ||
|
|
f702c956a5 | ||
|
|
ca58e2f0a6 | ||
|
|
c3c2528c30 | ||
|
|
1e66f11917 | ||
|
|
bb1401a683 | ||
|
|
96f48ca102 | ||
|
|
671ff23085 | ||
|
|
5e2221796a | ||
|
|
adc2d9cb4d | ||
|
|
d5626d31e6 | ||
|
|
b0f6c95fca | ||
|
|
8884116ee9 | ||
|
|
885c18c0d4 | ||
|
|
527c98d378 | ||
|
|
863d90c7fb | ||
|
|
80f0c63113 | ||
|
|
4628e0ad59 | ||
|
|
a786f186a2 | ||
|
|
b5ed0800cf | ||
|
|
de47989527 | ||
|
|
4e36fa1b4e | ||
|
|
5f25aad273 | ||
|
|
c2a3d4361a | ||
|
|
688ddc529b | ||
|
|
790f880e98 | ||
|
|
707c0bb453 | ||
|
|
7dce25423c | ||
|
|
705fb90740 | ||
|
|
8704a24f26 | ||
|
|
6f6fe54d5c | ||
|
|
d2861c4457 | ||
|
|
ae2c5cf98e | ||
|
|
d64bf6015e | ||
|
|
69bdc8e884 | ||
|
|
b46efeb4e3 | ||
|
|
81d6369a34 | ||
|
|
32d70a83d1 | ||
|
|
1f61e49599 | ||
|
|
b976de926b | ||
|
|
880216b775 | ||
|
|
e530bbc83a | ||
|
|
a67a4a41da | ||
|
|
2bdb03b0a9 | ||
|
|
8733575255 | ||
|
|
9742eb1645 | ||
|
|
c87493af0c | ||
|
|
d8301f3c61 | ||
|
|
c38a63692d | ||
|
|
1f19f1bfcb | ||
|
|
c0f65e0610 | ||
|
|
1985abbe6c | ||
|
|
e74065a493 | ||
|
|
96210373f3 | ||
|
|
7bbb44d4e6 | ||
|
|
943c0d9cb4 | ||
|
|
001db4befe | ||
|
|
27af142a87 | ||
|
|
ede150d80f | ||
|
|
699ef2c3cb | ||
|
|
1ae686b3ea | ||
|
|
6d840fc1e7 | ||
|
|
c62ea697fe | ||
|
|
8c17adf15b | ||
|
|
867bb0c646 | ||
|
|
ddba58ca99 | ||
|
|
6cab1fa842 | ||
|
|
4439691695 | ||
|
|
1d729adbb7 | ||
|
|
0df672ecca | ||
|
|
0c4387769e | ||
|
|
5748f834ff | ||
|
|
a2914bda33 | ||
|
|
d186f85773 | ||
|
|
c248363895 | ||
|
|
20330bc2c3 | ||
|
|
f12a9926eb | ||
|
|
f00995a7f9 | ||
|
|
58914042cc | ||
|
|
a2eb98ac93 | ||
|
|
853866492b | ||
|
|
448cfbbb51 | ||
|
|
1095d2466d | ||
|
|
94cb607930 | ||
|
|
8b0d7a67b5 | ||
|
|
94d67cef71 | ||
|
|
07160c2fe0 | ||
|
|
ec734c92bc | ||
|
|
90a88f7e01 | ||
|
|
42ae4d6897 | ||
|
|
11ffee646e | ||
|
|
e453a80155 | ||
|
|
f802e6ba67 | ||
|
|
ab52cc35bf | ||
|
|
a0b02ee68b | ||
|
|
fae6ed3297 | ||
|
|
a0807b22a4 | ||
|
|
2c0c84b191 | ||
|
|
d5bcd34cdb | ||
|
|
ffe5040644 | ||
|
|
faadf873fb | ||
|
|
1434ede2dc | ||
|
|
f1edbd78cb | ||
|
|
0a4368d1c0 | ||
|
|
6a404d9fb3 | ||
|
|
8c724e3bc5 | ||
|
|
70d2b319b3 | ||
|
|
8b5c66d900 | ||
|
|
ceb7e48c99 | ||
|
|
9df6717364 | ||
|
|
47c5a727b5 | ||
|
|
654df76065 | ||
|
|
e3e5867d60 | ||
|
|
b9c3458cc2 | ||
|
|
ee9c200ebf | ||
|
|
808eda0513 | ||
|
|
63dc370ea0 | ||
|
|
26827ec8a7 | ||
|
|
87e52b804e | ||
|
|
702c323610 | ||
|
|
d47fd20926 | ||
|
|
51f7430a4b | ||
|
|
fc6e9d0002 | ||
|
|
f23e442c4c | ||
|
|
6eda1f78d5 | ||
|
|
142cf8a626 | ||
|
|
56d3997675 | ||
|
|
82ed0adfc3 | ||
|
|
9f31394318 | ||
|
|
2a1821993c | ||
|
|
3cc9ec3c68 | ||
|
|
52d3bba6f6 | ||
|
|
0de70df472 | ||
|
|
7517917832 | ||
|
|
af9ed19af0 | ||
|
|
b78ca31b09 | ||
|
|
b66138587b | ||
|
|
80e1904175 | ||
|
|
7314dc99c8 | ||
|
|
97e05c0610 | ||
|
|
2c9b07b8d6 | ||
|
|
60f09b11f9 | ||
|
|
54cb483a31 | ||
|
|
d58602f1a1 | ||
|
|
d84445baa1 | ||
|
|
d73f26c83f | ||
|
|
23ba5d612a | ||
|
|
b15b1a6007 | ||
|
|
e2dea35e02 | ||
|
|
11d820b085 | ||
|
|
4986a06cdf | ||
|
|
621f850804 | ||
|
|
2afddafe4d | ||
|
|
2c9050824e | ||
|
|
cccb27c733 | ||
|
|
71166af75b | ||
|
|
da25196830 | ||
|
|
17943c48c3 | ||
|
|
62e69e3dbf | ||
|
|
838170a9ff | ||
|
|
a4893751e9 | ||
|
|
ee105e0e28 | ||
|
|
55da662344 | ||
|
|
425daac6b6 | ||
|
|
c3d78a8cb8 | ||
|
|
1afbbe04e1 | ||
|
|
36ced0d3ba | ||
|
|
318a4301c0 | ||
|
|
29eccb27ed | ||
|
|
06f28eced9 | ||
|
|
cc8ec36e5a | ||
|
|
a1148c7ec9 | ||
|
|
b1a6628bfa | ||
|
|
6d1ff4c178 | ||
|
|
0b3550b472 | ||
|
|
50f12c719b | ||
|
|
477b600b1b | ||
|
|
946c6e93c4 | ||
|
|
880fc77b88 | ||
|
|
94fbb8a92f | ||
|
|
d088219d8d | ||
|
|
47375ec075 | ||
|
|
115cb3fd5d | ||
|
|
8bf5296fe6 | ||
|
|
9c9adf7c8e | ||
|
|
e51a644788 | ||
|
|
e59027f557 | ||
|
|
4024969be8 | ||
|
|
f2736bae40 | ||
|
|
a26dcf5075 | ||
|
|
8108fcac81 | ||
|
|
8256f06a6a | ||
|
|
25e65910dd | ||
|
|
ef32f6f466 | ||
|
|
23089aa1af | ||
|
|
3036507baa | ||
|
|
c96a13ef30 | ||
|
|
3fac5b00d3 | ||
|
|
3e4acaaf4e | ||
|
|
799cfe7ff2 | ||
|
|
6104843858 | ||
|
|
e011625a2d | ||
|
|
b72fa25d3f | ||
|
|
ec0fded0cc | ||
|
|
5ec7c9f99c | ||
|
|
6d523096ce | ||
|
|
f875843c5c | ||
|
|
954bc9b314 | ||
|
|
a66e930997 | ||
|
|
2ca8e69025 | ||
|
|
679c00527f | ||
|
|
2a52a82fb7 | ||
|
|
15ea9a91dc | ||
|
|
e0c197b9bc | ||
|
|
2988cd962c | ||
|
|
dedb42447c | ||
|
|
74e9ed8257 | ||
|
|
3a321b39e1 | ||
|
|
90798205e0 | ||
|
|
a1ee147c46 | ||
|
|
5eb91be6fb | ||
|
|
83695e3e4c | ||
|
|
ae542e9501 | ||
|
|
a5c7eb3a27 | ||
|
|
6af25e085c | ||
|
|
bc574ee1cb | ||
|
|
bfe119eaa3 | ||
|
|
bb58670597 | ||
|
|
c2fa873d85 | ||
|
|
d9e94648d3 | ||
|
|
fd709fd98c | ||
|
|
8d98eaf3a5 | ||
|
|
789c02b5c7 | ||
|
|
a1c9bc5f9b | ||
|
|
2de029b96e | ||
|
|
1a9cbd26b3 | ||
|
|
a96a8a6d25 | ||
|
|
93b19110df | ||
|
|
295cc7543a | ||
|
|
183d2921a8 | ||
|
|
156d517976 | ||
|
|
cb21a63ac2 | ||
|
|
0f9224a4ef | ||
|
|
adb9ebafe1 | ||
|
|
87172dfb8d | ||
|
|
7a9aec74b5 | ||
|
|
790b03bc03 | ||
|
|
04b46540fb | ||
|
|
7f8a700716 | ||
|
|
bcd94709d2 | ||
|
|
9a967a5779 | ||
|
|
a448d87e3a | ||
|
|
538159dd71 | ||
|
|
a8af39e675 | ||
|
|
05e9230e9e | ||
|
|
f662437b3f | ||
|
|
496ebd18a5 | ||
|
|
6a2aaf3d26 | ||
|
|
56106d41eb | ||
|
|
a32fb81273 | ||
|
|
10528fbb90 | ||
|
|
b4259f59cf | ||
|
|
116eceaac4 | ||
|
|
6fc3abafd5 | ||
|
|
70ad295a89 | ||
|
|
6ae4c8ef46 | ||
|
|
8662243ed1 | ||
|
|
0af1a25c93 | ||
|
|
57c7e04f04 | ||
|
|
2960db2cca | ||
|
|
5426d9b2bd | ||
|
|
17e4b088f1 | ||
|
|
475182efd0 | ||
|
|
c60abdc349 | ||
|
|
43e6dd60b3 | ||
|
|
b7b7aa737a | ||
|
|
1b2a20c395 | ||
|
|
50703f8ae6 | ||
|
|
27633d51b6 | ||
|
|
4f6452c90c | ||
|
|
5bb10ef970 | ||
|
|
59eca88e91 | ||
|
|
bfe523ac45 | ||
|
|
fda2d7f574 | ||
|
|
1ba091fd0e | ||
|
|
5e01f427af | ||
|
|
0341d41165 | ||
|
|
082d07d718 | ||
|
|
2d8b35291d | ||
|
|
a7dedcd3b7 | ||
|
|
d39b525b07 | ||
|
|
351fcba4f8 | ||
|
|
3d186a24ff | ||
|
|
5ad89f072f | ||
|
|
98dc0174d6 | ||
|
|
d29715e9ad | ||
|
|
58e5d96e1d | ||
|
|
033de0e20a | ||
|
|
6d7a2d46d6 | ||
|
|
bf92dde28d | ||
|
|
0eabdf011b | ||
|
|
91af66e2db | ||
|
|
8db1b48691 | ||
|
|
3f6a7e4336 | ||
|
|
ddc548f590 | ||
|
|
39ed098a64 | ||
|
|
13e12f7335 | ||
|
|
7c6aaeb05d | ||
|
|
fb75e47eda | ||
|
|
e6add2f7e6 | ||
|
|
32cf9321cc | ||
|
|
dfadc62720 | ||
|
|
fb4ffaffed | ||
|
|
0ebb2f5858 | ||
|
|
87598b24ee | ||
|
|
4e9f3ee173 | ||
|
|
4eb4bb9623 | ||
|
|
7bb33554f4 | ||
|
|
2ae8270a1d | ||
|
|
20e620993b | ||
|
|
660d0f08a1 | ||
|
|
53a5d7ea6f | ||
|
|
fafdac21fb | ||
|
|
f863aee3c2 | ||
|
|
2384c48749 | ||
|
|
7072b94355 | ||
|
|
7d34c60dd1 | ||
|
|
af79e2abbd | ||
|
|
9b4ca86650 | ||
|
|
bddde30be4 | ||
|
|
3eb63db313 | ||
|
|
6039715a53 | ||
|
|
85cec761e1 | ||
|
|
476f460f75 | ||
|
|
40a0b929d0 | ||
|
|
82c6448149 | ||
|
|
9fb3d6d8db | ||
|
|
dfcbc559b8 | ||
|
|
3b57c1e0ae | ||
|
|
2ab915cc00 | ||
|
|
921ebad8fb | ||
|
|
a40fefc8d7 | ||
|
|
9a1c7b2ecd | ||
|
|
14934a0d3a | ||
|
|
e82914c4e1 | ||
|
|
190db2f98b | ||
|
|
5570d2da38 | ||
|
|
949ba708ea | ||
|
|
bcb2365c9a | ||
|
|
259d18eb30 | ||
|
|
04852196ad | ||
|
|
fc1daa7e71 | ||
|
|
e234d3db36 | ||
|
|
7b8127fef8 | ||
|
|
db84b5a6ce | ||
|
|
c79ced28dd | ||
|
|
2408dc8985 | ||
|
|
2953eb17d6 | ||
|
|
c9619397b6 | ||
|
|
c8a6b8094e | ||
|
|
7ee9c60e38 | ||
|
|
4b0a33174d | ||
|
|
db27650b60 | ||
|
|
aadf24db99 | ||
|
|
2496e756f1 | ||
|
|
5ba9f94b7e | ||
|
|
9fa41e37f3 | ||
|
|
d184b9db81 | ||
|
|
08e7434d24 | ||
|
|
0245ccd16c | ||
|
|
deb6842a4d | ||
|
|
ac62f66aa1 | ||
|
|
097827b515 | ||
|
|
9a3edc8c11 | ||
|
|
096f338277 | ||
|
|
db562ce6a0 | ||
|
|
8017497bdd | ||
|
|
3cb9843f23 | ||
|
|
a063cf2db5 | ||
|
|
72357dfd23 | ||
|
|
0df6701326 | ||
|
|
f5e558cb2f | ||
|
|
995f0934ed | ||
|
|
ef74436cde | ||
|
|
51c025c79b | ||
|
|
12767fd58c | ||
|
|
826d89c7dc | ||
|
|
393652c1d9 | ||
|
|
246ce73d94 | ||
|
|
951bad4134 | ||
|
|
ae17b00b6f | ||
|
|
36808b34de | ||
|
|
105f99ced3 | ||
|
|
430a64722e | ||
|
|
7e9604a254 | ||
|
|
5369f283d7 | ||
|
|
654465e4eb | ||
|
|
c5a0c0a873 | ||
|
|
16a466aed0 | ||
|
|
3e836c1dd3 | ||
|
|
ddf15f9b92 | ||
|
|
54897c1447 | ||
|
|
2a221456d9 | ||
|
|
48a57bf16c | ||
|
|
8742b4127c | ||
|
|
e32637503d | ||
|
|
2401fe25fa | ||
|
|
f0b60119cb | ||
|
|
4d5597a1da | ||
|
|
2973485321 | ||
|
|
1e4292876c | ||
|
|
ca6464baeb | ||
|
|
c2cb58d088 | ||
|
|
38c249ec70 | ||
|
|
2e1c1fd3c8 | ||
|
|
4edc7a2949 | ||
|
|
9b5c6f8554 | ||
|
|
0de964ec02 | ||
|
|
7db091d427 | ||
|
|
9cf7dea579 | ||
|
|
9465a488cc | ||
|
|
5e94198216 | ||
|
|
854d2b3c79 | ||
|
|
946847c95d | ||
|
|
ce95f868fd | ||
|
|
e5a63a3508 | ||
|
|
1e4d709a06 | ||
|
|
a93f048814 | ||
|
|
5319ff3907 | ||
|
|
68b8ac6b8c | ||
|
|
a979c0cfac | ||
|
|
3da8c30a64 | ||
|
|
f8c72a1e0a | ||
|
|
4a059efe18 | ||
|
|
dec5467538 | ||
|
|
61cd5bd76c | ||
|
|
1b1524e787 | ||
|
|
488d81f424 | ||
|
|
1ff1e3e5f8 | ||
|
|
decee0ecc2 | ||
|
|
170a3d3cf7 | ||
|
|
601ebba919 | ||
|
|
fd1f3329bd | ||
|
|
fd72cd4fd5 | ||
|
|
200ccf8e06 | ||
|
|
285d71e13d | ||
|
|
8de76b5cfe | ||
|
|
8ecc6d1c83 | ||
|
|
6da7b84757 | ||
|
|
072e95046a | ||
|
|
5aa4d99ea3 | ||
|
|
f1ac22881c | ||
|
|
fb85869d09 | ||
|
|
c69672ec60 | ||
|
|
3e1a41393a | ||
|
|
e9225a4b2c | ||
|
|
df80effbdf | ||
|
|
35a60e798e | ||
|
|
dfd0cb2d36 | ||
|
|
da941a299c | ||
|
|
81e3c9e19b | ||
|
|
b9e8ffa3d1 | ||
|
|
62efc63830 | ||
|
|
e6e5805a49 | ||
|
|
a3d98595e2 | ||
|
|
6af34dc3a6 | ||
|
|
1cf3e39d72 | ||
|
|
b192c82dcc | ||
|
|
9138efca6f | ||
|
|
52238de9b3 | ||
|
|
cc064c2fd1 | ||
|
|
baf7dbfe0d | ||
|
|
0bc6cf1178 | ||
|
|
8638632600 | ||
|
|
6d5d9ced47 | ||
|
|
e825f643e2 | ||
|
|
c4f656996e | ||
|
|
103c0e3820 | ||
|
|
6da349c888 | ||
|
|
15c3bb81a6 | ||
|
|
4a628df108 | ||
|
|
25ae9b9a0d | ||
|
|
6b10f2ab7d | ||
|
|
770e6a41d4 | ||
|
|
375f591fe5 | ||
|
|
fcdbf59f7a | ||
|
|
6c80227103 | ||
|
|
0016c6271a | ||
|
|
c557b7d6b2 | ||
|
|
64c328c53d | ||
|
|
e5994287fb | ||
|
|
7126014619 | ||
|
|
3b8e89ea00 | ||
|
|
e72ee3ecaf | ||
|
|
31cfc45e58 | ||
|
|
834fc8cf98 | ||
|
|
eb3b7df390 | ||
|
|
76882ad1c4 | ||
|
|
15381ac260 | ||
|
|
b7e6acb24e | ||
|
|
b86305db8c | ||
|
|
fe803a99ca | ||
|
|
4612826664 | ||
|
|
a5a99e2ab1 | ||
|
|
56584a25c8 | ||
|
|
03ba7cb68c | ||
|
|
cc605250db | ||
|
|
ae34a86cbf | ||
|
|
86de5ae151 | ||
|
|
bcc25047f2 | ||
|
|
f5ce0a8a8c | ||
|
|
55bbc10986 | ||
|
|
4fef2d8143 | ||
|
|
4432a689bb | ||
|
|
6013fcd528 | ||
|
|
71fb8541d5 | ||
|
|
4398d33fea | ||
|
|
92814aa9b3 | ||
|
|
69d4a883ad | ||
|
|
2e5617c4b5 | ||
|
|
f930ed36a0 | ||
|
|
83b720eade | ||
|
|
c3a1b420ea | ||
|
|
c889d8f325 | ||
|
|
e23d07e965 | ||
|
|
08a8182173 | ||
|
|
9e7a7a602f | ||
|
|
6e5e5f0aeb | ||
|
|
7769c1f89e | ||
|
|
a2a5cf6d4c | ||
|
|
77e9b929dc | ||
|
|
b6306031dc | ||
|
|
0564eee400 | ||
|
|
f548ac2ecc | ||
|
|
c9fd99589b | ||
|
|
23d8a819af | ||
|
|
14b3331c8e | ||
|
|
f266c441db | ||
|
|
ed73e3ad1e | ||
|
|
4f9a8fb45c | ||
|
|
6910737374 | ||
|
|
bdfe8e630e | ||
|
|
66719375af | ||
|
|
ef3d5209eb | ||
|
|
e4eac3f9ad | ||
|
|
107284e796 | ||
|
|
bad86a5ff2 | ||
|
|
034ca4c250 | ||
|
|
2b101d594a | ||
|
|
c40362fa7f | ||
|
|
e8b515c733 | ||
|
|
e5582b5558 | ||
|
|
3b6ebeb653 | ||
|
|
bfec83996a | ||
|
|
4a72c14b74 | ||
|
|
f529457f96 | ||
|
|
642504874b | ||
|
|
d99e160a27 | ||
|
|
714e590496 | ||
|
|
bfcb57002d | ||
|
|
8b59c89322 | ||
|
|
31e9f59d8d | ||
|
|
0c74aef68e | ||
|
|
18e9fc8e7c | ||
|
|
1035389c9e | ||
|
|
8c62213a52 | ||
|
|
e7248bfd98 | ||
|
|
8c0e883b23 | ||
|
|
e3d8aa1a9b | ||
|
|
9aebeeac02 | ||
|
|
049ccee94e | ||
|
|
c59267d5ee | ||
|
|
c9ae964eee | ||
|
|
89cf89670f | ||
|
|
3b659116db | ||
|
|
ddd1bde1b4 | ||
|
|
715ab343bf | ||
|
|
7430212ac8 | ||
|
|
33efb32aa9 | ||
|
|
679eec05bc | ||
|
|
b019d44d0b | ||
|
|
efc71580ea | ||
|
|
90dacd98b2 | ||
|
|
f9a1e28074 | ||
|
|
de3f5da9d4 | ||
|
|
abe2acd0dc | ||
|
|
de48d807f1 | ||
|
|
22032ae4f8 | ||
|
|
3e617a81cf | ||
|
|
9005b6cef9 | ||
|
|
f4635a79f9 | ||
|
|
8a0c1e8a4b | ||
|
|
e3b662717b | ||
|
|
cbfc0adf3d | ||
|
|
4a29c13acb | ||
|
|
60e434e4ac | ||
|
|
d126e7f25a | ||
|
|
ecbce16c99 | ||
|
|
0576308187 | ||
|
|
f2c9cd0c62 | ||
|
|
f72995ec8b | ||
|
|
2ffb848fe6 | ||
|
|
e63f1246da | ||
|
|
dbe623c9a7 | ||
|
|
dbe01c6c23 | ||
|
|
29917fb85a | ||
|
|
1a1b4fb0d2 | ||
|
|
4027a4f310 | ||
|
|
37052e1f68 | ||
|
|
98f801e244 | ||
|
|
f9ef2ebbe1 | ||
|
|
a939e11641 | ||
|
|
a4a667ef06 | ||
|
|
87218e4803 | ||
|
|
40301f59b7 | ||
|
|
5db9cad7e5 | ||
|
|
e4bdf996f7 | ||
|
|
c1d0404f23 | ||
|
|
6bb2c50e5c | ||
|
|
8a85cfc8da | ||
|
|
86bd07666f | ||
|
|
a578719fc5 | ||
|
|
72e0b6a3b1 | ||
|
|
90a400b782 | ||
|
|
80eae94fe1 | ||
|
|
5daa492f68 | ||
|
|
b43a181bf4 | ||
|
|
a31ba31567 | ||
|
|
b03b201c20 | ||
|
|
af995428bc | ||
|
|
d79e30f9be | ||
|
|
f5228e6c2e | ||
|
|
ea77e09058 | ||
|
|
5ab4b95e3c | ||
|
|
8dc1415404 | ||
|
|
122bab303d | ||
|
|
160051fc3c | ||
|
|
500baffb2b | ||
|
|
4c77f5ac31 | ||
|
|
8ad518e7b6 | ||
|
|
1faf6df5a3 | ||
|
|
fc08438164 | ||
|
|
cc562a451e | ||
|
|
782e93f4a1 | ||
|
|
cf1e333191 | ||
|
|
31f1099dc0 | ||
|
|
ddaaf15b2e | ||
|
|
a6cafeb5c6 | ||
|
|
9b34b949a6 | ||
|
|
82c3d27bff | ||
|
|
0e9893fcb6 | ||
|
|
f37c387d52 | ||
|
|
7a49e8aa15 | ||
|
|
c80be802d1 | ||
|
|
ff9b05fc85 | ||
|
|
55f3dac9b6 | ||
|
|
1b082c6a25 | ||
|
|
5b78a86166 | ||
|
|
7d40b086e4 | ||
|
|
59b2338891 | ||
|
|
af525c01ad | ||
|
|
2be91cbd3a | ||
|
|
cc438574ff | ||
|
|
09e4256c18 | ||
|
|
a0f5864324 | ||
|
|
12db0d5a84 | ||
|
|
35f54a0ee8 | ||
|
|
2e314ff098 | ||
|
|
0066a06107 | ||
|
|
544d8deb81 | ||
|
|
e730718671 | ||
|
|
a96fae5ffd | ||
|
|
b53ed7f6ca | ||
|
|
dd216318b0 | ||
|
|
4a8a4c8227 | ||
|
|
177278fee3 | ||
|
|
6ffcb8c451 | ||
|
|
bda103d7a0 | ||
|
|
2eadc3ea10 | ||
|
|
b352e89b48 | ||
|
|
19ebee4697 | ||
|
|
4014eb4ec2 | ||
|
|
e94662350a | ||
|
|
0ae4fa0d2e | ||
|
|
b0067eccc6 | ||
|
|
95b2262cc3 | ||
|
|
7cef5528b3 | ||
|
|
a7d728c3ac | ||
|
|
4e61c62965 | ||
|
|
1c4010a744 | ||
|
|
cbfe6fbc11 | ||
|
|
e9c3c38a53 | ||
|
|
322570f298 | ||
|
|
8b857224dc | ||
|
|
d3ea85d5ac | ||
|
|
f6ab825692 | ||
|
|
67219ede6b | ||
|
|
8b32267858 | ||
|
|
0a469a0524 | ||
|
|
65c6fd592c | ||
|
|
a6ce755743 | ||
|
|
c2e7a02e5a | ||
|
|
ef0218ddfe | ||
|
|
ea645da3cb | ||
|
|
9a42f784ff | ||
|
|
67002e4b71 | ||
|
|
166416339a | ||
|
|
ead2c6d2c1 | ||
|
|
bfea6cb28e | ||
|
|
7ecac539f9 | ||
|
|
e73599a800 | ||
|
|
f46f40ad4a | ||
|
|
058197dc99 | ||
|
|
8fb6784964 | ||
|
|
c16dc74ad9 | ||
|
|
cebe83542f | ||
|
|
6c19145734 | ||
|
|
0a6e26bb2d | ||
|
|
de629d9416 | ||
|
|
48786f1e59 | ||
|
|
3d26371fa9 | ||
|
|
73c713462c | ||
|
|
16d2ab1646 | ||
|
|
f9048d7027 | ||
|
|
d627c5e094 | ||
|
|
6458810571 | ||
|
|
e1549c1b0c | ||
|
|
9542246414 | ||
|
|
a2d56d4a8d | ||
|
|
72d0994ca6 | ||
|
|
f07082b52d | ||
|
|
941639666d | ||
|
|
bd53339532 | ||
|
|
28da8dcc58 | ||
|
|
ceae2b25b5 | ||
|
|
579f6421e4 | ||
|
|
a904a8fb9d | ||
|
|
b70303c437 | ||
|
|
a8e1971483 | ||
|
|
78ce6fc382 | ||
|
|
71804df3e0 | ||
|
|
5ce3fb6cc6 | ||
|
|
6c4e094d44 | ||
|
|
f007dd640d | ||
|
|
e1c7d35ce4 | ||
|
|
a17e9295e7 | ||
|
|
b814d8a400 | ||
|
|
fe9cef5e1e | ||
|
|
415f574e2d | ||
|
|
63f76ce87a | ||
|
|
19259b2226 | ||
|
|
53edd7cce6 | ||
|
|
2069689780 | ||
|
|
d6f09f4c1a | ||
|
|
85a984734b | ||
|
|
b78eafaedb | ||
|
|
641d8e9fc5 | ||
|
|
bdd70e53dc | ||
|
|
7797ee708a | ||
|
|
6c9d3d8893 | ||
|
|
3a3a7597bf | ||
|
|
d0befe5944 | ||
|
|
a3729504e5 | ||
|
|
4efd09de31 | ||
|
|
6a29cd9715 | ||
|
|
9869d92ead | ||
|
|
a248a67d10 | ||
|
|
bec30c9927 | ||
|
|
38b7d0f600 | ||
|
|
0366334e7c | ||
|
|
b538960592 | ||
|
|
ac17b8497a | ||
|
|
2c74a67738 | ||
|
|
77cf4947b6 | ||
|
|
c6a0435751 | ||
|
|
7e9f1562e4 | ||
|
|
1de8b76303 | ||
|
|
b4cbe2355d | ||
|
|
09c3f82226 | ||
|
|
01e442c42e | ||
|
|
75dfc66e9a | ||
|
|
f8153bd784 | ||
|
|
b7c7e79a86 | ||
|
|
207a28cb44 | ||
|
|
9660fa0801 | ||
|
|
4ca092f351 | ||
|
|
99fa6b4b12 | ||
|
|
fb30499d80 | ||
|
|
2a2c188aec | ||
|
|
c101fa7a32 | ||
|
|
40bd3edb32 | ||
|
|
e944364fc2 | ||
|
|
a2c72b7fd1 | ||
|
|
2117271992 | ||
|
|
0e21060ea3 | ||
|
|
dea3a30cae | ||
|
|
b84c5b4293 | ||
|
|
201eb89dcd | ||
|
|
1b901f5d1c | ||
|
|
9243731646 | ||
|
|
06a0255547 | ||
|
|
b89688597a | ||
|
|
3592ff3269 | ||
|
|
a094a06cd1 | ||
|
|
aa3dfdbc0a | ||
|
|
16525ae07c | ||
|
|
e8405cbf77 | ||
|
|
6eef4bd300 | ||
|
|
699e3994df | ||
|
|
b681ea7243 | ||
|
|
29a3c26b44 | ||
|
|
35cb9f8698 | ||
|
|
6204c73ef2 | ||
|
|
95e1b3e349 | ||
|
|
30f345be55 | ||
|
|
231505c6fe | ||
|
|
d87252d52f | ||
|
|
b3e518d570 | ||
|
|
3b13dfdcb5 | ||
|
|
2e5333af29 | ||
|
|
450e27e001 | ||
|
|
71164faa14 | ||
|
|
a89106f4a3 | ||
|
|
d768019e1b | ||
|
|
30b7fd8767 | ||
|
|
faae6ccf97 | ||
|
|
6c11cefb99 | ||
|
|
dd8f9dbfd5 | ||
|
|
93752e671e | ||
|
|
7b75d4f0bb | ||
|
|
1c8a2fef32 | ||
|
|
69221a310c | ||
|
|
49b5b4412f | ||
|
|
fe86041fc4 | ||
|
|
957983d8a5 | ||
|
|
8f176c969b | ||
|
|
79d20260c5 | ||
|
|
0079d8634a | ||
|
|
a08087c5e8 | ||
|
|
cdb048741c | ||
|
|
4d0720ade8 | ||
|
|
e5e1851852 | ||
|
|
0b249b6018 | ||
|
|
c65494f093 | ||
|
|
e82f849118 | ||
|
|
87da492ef8 | ||
|
|
9d2ac2b81f | ||
|
|
c78bf02ce4 | ||
|
|
2f7419cf3b | ||
|
|
10303f979d | ||
|
|
b742291131 | ||
|
|
a06781ebc3 | ||
|
|
f5e28f6815 | ||
|
|
770a6664d4 | ||
|
|
f89e66e3cb | ||
|
|
65a25e2510 | ||
|
|
5b74432ee3 | ||
|
|
49eb966315 | ||
|
|
42e680f09d | ||
|
|
31c0925354 | ||
|
|
4c9b91ab57 | ||
|
|
a92ae95ab1 | ||
|
|
fc8edeba5f | ||
|
|
db1f239a0f | ||
|
|
c8c65d2e92 | ||
|
|
67654fdf85 | ||
|
|
63a656cd61 | ||
|
|
36865cfcf9 | ||
|
|
d493c2d671 | ||
|
|
2a0ae12829 | ||
|
|
6a4875c5ec | ||
|
|
325c08389e | ||
|
|
bcd1290301 | ||
|
|
bd1414dd1c | ||
|
|
d6623f89aa | ||
|
|
0494858886 | ||
|
|
df94ff1d8d | ||
|
|
85111e233e | ||
|
|
5337280eec | ||
|
|
3ce32ba870 | ||
|
|
6cb33a8b63 | ||
|
|
10cebd9e09 | ||
|
|
0c554cc754 | ||
|
|
f3b918e2c2 | ||
|
|
f67f56e6de | ||
|
|
0c0626564a | ||
|
|
019ffc25f0 | ||
|
|
690e566556 | ||
|
|
eee4c69575 | ||
|
|
adbbdef1a8 | ||
|
|
36e5606e25 | ||
|
|
5fb7a33199 | ||
|
|
db0a3fb85e | ||
|
|
1215bbf3ab | ||
|
|
64ac5a181a | ||
|
|
4834c289ff | ||
|
|
5fffc282fe | ||
|
|
6dbf29153e | ||
|
|
542d8a798f | ||
|
|
7d8b55adb6 | ||
|
|
1eca2ec6e6 | ||
|
|
9ee3fe6c37 |
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# No need to invalidate cache just because we try speeding up the build
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose*
|
||||
|
||||
# Install dependencies during the build
|
||||
node_modules
|
||||
packages/**/node_modules
|
||||
plugins/**/node_modules
|
||||
|
||||
# Download factorio during the build
|
||||
factorio
|
||||
factorio.tar.gz
|
||||
|
||||
# We build internal mods during build, mods from local test env should not be applied
|
||||
mods
|
||||
|
||||
# These files should never be required during the build
|
||||
.git
|
||||
.github
|
||||
dist
|
||||
temp
|
||||
@@ -1 +1 @@
|
||||
packages/master/static
|
||||
dist/
|
||||
@@ -1,27 +1,51 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"node",
|
||||
],
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es6": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11,
|
||||
"es2022": true,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["{packages,plugins}/**/*.ts?(x)"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
"prefer-const": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
"env": {
|
||||
"mocha": "true",
|
||||
},
|
||||
"parserOptions": { sourceType: "commonjs" },
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": ["packages/master/web/**/*.jsx"],
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
@@ -31,6 +55,44 @@ module.exports = {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "off",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -45,8 +107,7 @@ module.exports = {
|
||||
"arrow-spacing": ["error", { "after": true, "before": true }],
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
"brace-style": ["warn", "1tbs", { "allowSingleLine": true }],
|
||||
"callback-return": "off",
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
@@ -60,31 +121,33 @@ module.exports = {
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
"comma-spacing": "warn",
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": "warn",
|
||||
"complexity": "error",
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
"curly": "warn",
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
"dot-notation": ["warn", { "allowKeywords": true }],
|
||||
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
"func-style": ["error", "declaration"],
|
||||
"func-style": ["error", "declaration", {
|
||||
// Allow arrow functions as it better expresses referencing this
|
||||
// of the containing scope, which is unweilding in plain functions.
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": ["error", { "before": false, "after": true }],
|
||||
"global-require": "warn",
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"handle-callback-err": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
@@ -94,24 +157,27 @@ module.exports = {
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "error",
|
||||
"keyword-spacing": "warn",
|
||||
"keyword-spacing": "error",
|
||||
"line-comment-position": "off",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
"lines-between-class-members": ["error", "always"],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "warn",
|
||||
"max-len": ["warn", { "code": 120 }],
|
||||
"max-depth": "error",
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["warn", "separate-lines"],
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
"new-cap": "error",
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
}],
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
@@ -122,15 +188,15 @@ module.exports = {
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "error",
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "off",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "warn",
|
||||
"no-else-return": "error",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
@@ -143,7 +209,7 @@ module.exports = {
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "off",
|
||||
"no-invalid-this": "warn",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
@@ -160,7 +226,6 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-mixed-requires": "error",
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
@@ -172,19 +237,14 @@ module.exports = {
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-require": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-path-concat": "error",
|
||||
"no-plusplus": ["warn", { "allowForLoopAfterthoughts": true }],
|
||||
"no-process-env": "error",
|
||||
"no-process-exit": "warn",
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-modules": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": "error",
|
||||
@@ -192,9 +252,8 @@ module.exports = {
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": ["error", { "allow": ["yargs"] }],
|
||||
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
"no-spaced-func": "error",
|
||||
"no-sync": "error",
|
||||
"no-tabs": ["error", { "allowIndentationTabs": true }],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
@@ -205,16 +264,16 @@ module.exports = {
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-use-before-define": "warn",
|
||||
"no-use-before-define": "off",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-constructor": "off",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "warn",
|
||||
"no-void": "error",
|
||||
"no-var": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
@@ -224,10 +283,18 @@ module.exports = {
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
"operator-linebreak": "warn",
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "warn",
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
@@ -239,7 +306,7 @@ module.exports = {
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "warn",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
"radix": ["error", "always"],
|
||||
@@ -253,24 +320,79 @@ module.exports = {
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "error",
|
||||
"space-before-blocks": "warn",
|
||||
"sort-vars": "off",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
"spaced-comment": ["warn", "always"],
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
"strict": "error",
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
"valid-jsdoc": ["error", { "requireReturn": false }],
|
||||
"vars-on-top": "warn",
|
||||
"valid-jsdoc": ["error", {
|
||||
"requireReturn": false,
|
||||
"requireReturnType": false,
|
||||
"requireParamType": false,
|
||||
}],
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": ["error", "never", { "exceptRange": true }],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
// Node.js callback passing style of (err, value) is not used in this project
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": ["error", {
|
||||
"tryExtensions": [".js", ".ts"],
|
||||
}],
|
||||
"node/no-missing-require": "error",
|
||||
"node/no-mixed-requires": "error",
|
||||
"node/no-new-require": "error",
|
||||
"node/no-path-concat": "error",
|
||||
"node/no-process-env": "error",
|
||||
"node/no-process-exit": "error",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "error",
|
||||
"node/no-unpublished-import": "error",
|
||||
"node/no-unpublished-require": [
|
||||
"error",
|
||||
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
|
||||
],
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "error",
|
||||
"node/prefer-global/console": "error",
|
||||
"node/prefer-global/process": "error",
|
||||
"node/prefer-global/text-decoder": "error",
|
||||
"node/prefer-global/text-encoder": "error",
|
||||
"node/prefer-global/url-search-params": "error",
|
||||
"node/prefer-global/url": "error",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "error",
|
||||
"node/shebang": ["error", {
|
||||
"convertPath": {
|
||||
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
package-lock.json -diff
|
||||
@@ -0,0 +1,52 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
with:
|
||||
version: 8
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ matrix.node-version != '18.x' }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.node-version == '18.x' }}
|
||||
run: pnpm run ci-cover
|
||||
- name : Upload coverage to Codecov
|
||||
if: ${{ matrix.node-version == '18.x' }}
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
@@ -1,7 +1,6 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
*debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
@@ -33,15 +32,15 @@ node_modules
|
||||
Factorio*
|
||||
/factorio
|
||||
/instances
|
||||
/sharedMods
|
||||
/mods
|
||||
/database
|
||||
/plugin-list.json
|
||||
/config-master.json
|
||||
/config-controller.json
|
||||
/config-control.json
|
||||
/config-slave.json
|
||||
package-lock.json
|
||||
/config-host.json
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
static/export
|
||||
/static/
|
||||
saves
|
||||
/temp/
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 10
|
||||
before_install:
|
||||
- curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64
|
||||
- tar -xf factorio.tar.gz
|
||||
- npm install -g codecov
|
||||
script:
|
||||
- npx lerna bootstrap --no-ci
|
||||
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --bump-patch --factorio-version 0.18 --output-dir temp/test/sharedMods
|
||||
- curl -o temp/test/sharedMods/subspace_storage_1.99.1.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.1/subspace_storage_1.99.1.zip
|
||||
- npm run-script ci-cover
|
||||
- cat temp/test/integration/log.txt
|
||||
- codecov -f coverage/lcov.info
|
||||
- npm run-script lint
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!settings.json
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"editor.detectIndentation": false,
|
||||
"editor.insertSpaces": false,
|
||||
"files.eol": "\n",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"[markdown]": {
|
||||
"editor.indentSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
},
|
||||
}
|
||||
@@ -1,243 +1,333 @@
|
||||
Changelog
|
||||
=========
|
||||
# Changelog
|
||||
|
||||
Version 2.0.0
|
||||
-------------
|
||||
## Version 2.0.0-alpha.17
|
||||
|
||||
### Features
|
||||
|
||||
- Made the host an instance is assigned to link to the host page for that host in the Web UI [#587](https://github.com/clusterio/clusterio/pull/587).
|
||||
- Added pagination to the saves list.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed an unhandled error in Subspace Storage crashing the host.
|
||||
- Fixed an unhandled rejection in disconnect logic crashing the host.
|
||||
- Fixed instance screen in Web UI not updating when an instance was reassigned.
|
||||
- Fixed an error thrown in the rendering of one page in the Web UI causing all pages to become inaccessible.
|
||||
- Fixed Web UI not showing instance saves when the permission to list and subscribe the saves is granted but the user is not a cluster admin.
|
||||
- Similarly fixed Web UI not showing the mod pack create, delete, list and host revoke token actions if the user is not a cluster admin.
|
||||
- Fixed Research Sync applying progress of a previous techology level to the level on the controller when it's the active research during instance startup.
|
||||
- Fixed data export with a mod using shorthand notation for the default value of a color-setting causing the ModPack data to be set to invalid data.
|
||||
- Fixed per user per instance player stats not being deleted when the instance it's for is deleted.
|
||||
- Fixed mods setting the item prototype icons property to an object mapping instead of an array breaking the data export.
|
||||
- Fixed a bad mod in the host's mods folder causing instance start to fail instead of the host re-downloading the mod.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the permission `core.instance.save.list.subscribe` to `core.instance.save.subscribe`.
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed message sequences not being renumbered when forwarded between links causing reconnects to send duplicated messages.
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.15
|
||||
|
||||
### Features
|
||||
|
||||
- Added support for `color-settings` Mod settings [#568](https://github.com/clusterio/clusterio/issues/568).
|
||||
- Added `clusterio_controller_pending_requests` and `clusterio_host_pending_requests` metrics.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed generated systemd file for clusteriohost not enabling source maps.
|
||||
- Fixed ctl not exporting types used for adding commands to ctl plugins.
|
||||
- Fixed controller only plugins throwing an error about missing a config field on startup out when added to hosts.
|
||||
- Fixed installer incorrectly claiming the options --factorio-dir and --no-download-headless conflicts with each other.
|
||||
- Fixed a memory leak on every request sent over the WebSocket.
|
||||
- Fixed assertion error on closing or invalidating a link with messages forwarded from a virtual link.
|
||||
- Fixed host hanging if stopped while an instance is in the process of creating a save during startup.
|
||||
- Fixed `instance.auto_start` option.
|
||||
- Fixed host log spammed with errors when plugins broadcast messages to instances with the plugin disabled.
|
||||
- Fixed properties of object config fields not being possible to remove.
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.14
|
||||
|
||||
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
|
||||
See the [migration guide](/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
|
||||
|
||||
### Major Features
|
||||
|
||||
- Replaced Hotpatch with save patching. Removed the Hotpatch scenario and
|
||||
the depency on it for getting code into the game. Added a save patcher than
|
||||
runs before starting up Factorio that patches in lua modules based on the
|
||||
event_loader lib into the savegame. Regular freeplay games can now be
|
||||
used with Clusterio and will be compatible without having to convert them
|
||||
to Hotpatch.
|
||||
- Daemonized slaves. Slaves now have the ability to run multiple Factorio
|
||||
instances, starting and stopping them individually. To manage this the
|
||||
local command line interface has been replaced with a remote interface on
|
||||
the master server that can be accessed through the clusterioctl cli tool.
|
||||
- Rewritten the communication between slaves and the master from scratch. The
|
||||
new system is based on a WebSocket connection between the slaves and the
|
||||
master server and provides efficient validated bi-directonal communication.
|
||||
- Rewritten the plugin system from scratch. Plugins now inherit from
|
||||
a base class and use the same WebSocket connection Clusterio uses to
|
||||
communicate.
|
||||
- New configuration system with support for initializing configs when needed,
|
||||
modifying config entries using built-in commands, and updating instance
|
||||
configuration remotely.
|
||||
- Packaged and uploaded to npm again, greatly simplifying distribution and
|
||||
installation of both Clusterio and plugins.
|
||||
- Added mod management. Mods can be uploaded to the controller and included in mod packs that can be assigned to instances and run.
|
||||
- Added host port ranges which the instance ports will be automatically assigned a port from if factorio.game_port is not set.
|
||||
- Added system info such as CPU model, hostname and kernel along with basic metrics for CPU usage, memory usage and disk usage for the controller and hosts.
|
||||
- Added ability to remotely view and modify the configuration of hosts.
|
||||
|
||||
### Features
|
||||
|
||||
- Added logging of Clusterio version on startup
|
||||
- Added `clusterio_controller_log_bytes` metric counting the size of the log files on the controller.
|
||||
- Added warning logged when plugins on a connecting host does not match the ones installed on the controller.
|
||||
- Added ability to revoke user tokens.
|
||||
- Added restart required indicator to config fields in the Web UI.
|
||||
- Added ability to revoke host tokens.
|
||||
- Added support for plugins to run in the context of a host.
|
||||
- Added more feedback when the web UI and or its plugins fails to load.
|
||||
- Added autosaving of data held in memory on the controller such as configs, instances, users, mods to prevent large data loss on crashing.
|
||||
- Added `system_info` metric with kernel, instruction set, CPU Model, hostname and Node.js version of the controller.
|
||||
- Added `clusterio_host_system_info` metric with kernel, instruction set, CPU Model, hostname and Node.js version of hosts.
|
||||
- Added `system_cpu_seconds_total`, `system_cpu_idle_seconds_total`, `system_memory_capacity_bytes`, `system_memory_available_bytes`, `system_disk_capacity_bytes`, `system_disk_available_bytes` metrics for CPU, memory and disk usage on the controller.
|
||||
- Added `clusterio_host_system_cpu_seconds_total`, `clusterio_host_system_cpu_idle_seconds_total`, `clusterio_host_system_memory_capacity_bytes`, `clusterio_host_system_memory_available_bytes`, `clusterio_host_system_disk_capacity_bytes`, `clusterio_host_system_disk_available_bytes` metrics for CPU, memory and disk usage on hosts.
|
||||
- Added support for reading the client's IP address from the `X-Forwarded-For` header when the source address is in the `controller.trusted_proxies` config option.
|
||||
- Added field showing the IP address a host is connecting from.
|
||||
- Added per player statistic tracking when they were first seen joining a particular server.
|
||||
- Added ability to remotely restart and/or stop the controller and hosts.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed filenames with extensions that are invalid on Windows such as "con.zip" not being treated as invalid.
|
||||
- Fixed generate host token modal not using a random id when the optional id field is unset.
|
||||
- Exported Factorio data is now tied to mod packs instead of being per cluster.
|
||||
- Fixed export failing if a mod uses a different root folder mod_version in its zip file.
|
||||
- Fixed sorting of user last seen column in the Web UI.
|
||||
- The list users permission is now granted by default to players.
|
||||
- Fixed incorrect handling of hosts and instance with an id of 0 causing them to not update properly.
|
||||
- Fixed unable to unassign instances in the Web UI.
|
||||
- Fixed host crashing if the Factorio server is not marked executable.
|
||||
- Fixed errors logged from instance not being attributed to the instance in certain situations.
|
||||
- Hosts no longer print messages from instances into their console.
|
||||
- Clusterio now treat unhandled Node.js errors and promise rejections as fatal and exit immediately.
|
||||
- Fixed a potential for data loss after a hard system crash due to fsync not being called on files written.
|
||||
- Fixed Factorio server crashing in the research_sync plugin when a mod enables a previously disabled technology and a player attempts to research it.
|
||||
- Fixed Factorio server crashing when the game starts with tick_paused = true
|
||||
- Removed migration of alpha 10's single file log format.
|
||||
- Log errors linking mods on Windows instead of preventing startup.
|
||||
- Added locale support for modules patched into to the game save.
|
||||
- Added support for defining a module_exports.lua file for modules patched into the game save.
|
||||
- Fixed controller logs being split on server's local time day boundary instead of UTC day boundary.
|
||||
- Fixed Unicode BOM in mod locale files breaking instance data export.
|
||||
- Added a warning to the server-setting.json that is automatically written to instance folders stating changes will be overwritten.
|
||||
- Changed save patching to use a `.tmp.zip` extension for the temporary file created instead of `zip.tmp`.
|
||||
- Fixed Inventory Sync wiping the player's inventory when logging out while in the Space Exploration remote view mode.
|
||||
- Fixed the public /api/plugins endponit leaking filesystem paths on the controller.
|
||||
- Refactored list subscriptions in the Web UI to cache and only fetch updated entries when navigating to previously seen list.
|
||||
- Added caching of save lists on the controller, enabling the saves of instances on offline hosts to still be seen.
|
||||
- Hosts in the assign instance dropdown is now sorted by name.
|
||||
- Added `non_blocking_saving` as one of the default settings added to `factorio.settings` on new instances.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed usage of the terms master and slave to controller and host in all places they occur.
|
||||
- Mods located in `sharedMods` are no longer copied to instances' mods folder.
|
||||
- The instances' mods folder is now managed through mod packs, mods not part of the mod pack will be automatically deleted.
|
||||
- Removed ability to import sub packages like `@clusterio/lib/link` from.
|
||||
- Replaced `clusterio_controller_http_hits_total` metric with `clusterio_controller_http_endpoint_duration_seconds`.
|
||||
- Replaced `clusterio_controller_last_query_log_duration_seconds` metric with `clusterio_controller_query_log_duration_seconds` summary.
|
||||
- Reworked the network protocol to be simpler to work and reason with.
|
||||
- Bumped minimum supported Node.js version to v18.
|
||||
- Ported the codebase to TypeScript
|
||||
- Changed the config format stored on disk to be much simpler.
|
||||
|
||||
### Contributors
|
||||
|
||||
Many thanks to the following for contributing code in this release:
|
||||
[@Hornwitser](https://github.com/Hornwitser) [@Luciole0x](https://github.com/Luciole0x) [@Cooldude2606](https://github.com/Cooldude2606) [@Danielv123](https://github.com/Danielv123) [@Reavershark](https://github.com/Reavershark) [@Laar](https://github.com/Laar) [@rstrom1763](https://github.com/rstrom1763)
|
||||
|
||||
## Version 2.0.0
|
||||
|
||||
### Major Features
|
||||
|
||||
- Replaced Hotpatch with save patching.
|
||||
Removed the Hotpatch scenario and the depency on it for getting code into the game.
|
||||
Added a save patcher than runs before starting up Factorio that patches in lua modules based on the event_loader lib into the savegame.
|
||||
Regular freeplay games can now be used with Clusterio and will be compatible without having to convert them to Hotpatch.
|
||||
- Daemonized hosts.
|
||||
Hosts now have the ability to run multiple Factorio instances, starting and stopping them individually.
|
||||
To manage this the local command line interface has been replaced with a remote interface on the controller that can be accessed through the clusterioctl cli tool.
|
||||
- Rewritten the communication between hosts and the controller from scratch.
|
||||
The new system is based on a WebSocket connection between the hosts and the controller and provides efficient validated bi-directonal communication.
|
||||
- Rewritten the plugin system from scratch.
|
||||
Plugins now inherit from a base class and use the same WebSocket connection Clusterio uses to communicate.
|
||||
- New configuration system with support for initializing configs when needed, modifying config entries using built-in commands, and updating instance configuration remotely.
|
||||
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
|
||||
- Added centralized logging for the cluster.
|
||||
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
|
||||
- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances.
|
||||
|
||||
### Features
|
||||
|
||||
- Added export of pollution statitics.
|
||||
- Reconnection logic that esures no data is dropped talking to the master
|
||||
server provided the session can be resumed.
|
||||
- Added support for having multiple Factorio installs and selecting which
|
||||
version to use on a per instance basis.
|
||||
- Added config to specify which path the master interface is accessed under,
|
||||
allowing it to be proxied behind web-server.
|
||||
- Reconnection logic that esures no data is dropped talking to the controller provided the session can be resumed.
|
||||
- Added support for having multiple Factorio installs and selecting which version to use on a per instance basis.
|
||||
- Added config to specify which path the controller interface is accessed under, allowing it to be proxied behind web-server.
|
||||
- Added WebSocket usage statistics.
|
||||
- Added commands sent statistic.
|
||||
- Added Factorio server CPU and memory usage statistic.
|
||||
- Added Factorio autosave size statistic.
|
||||
- Added support for extracting locale and item icons from Factorio and mods.
|
||||
- Added integrated user management for controlling access and storing per
|
||||
player data.
|
||||
- Added synchronization of in-game adminlist, banlist and whitelist to
|
||||
Clusterio core. Previously this was handled by the playerManager plugin.
|
||||
- Added integrated user management for controlling access and storing per player data.
|
||||
- Added synchronization of in-game adminlist, banlist and whitelist to Clusterio core.
|
||||
Previously this was handled by the playerManager plugin.
|
||||
- Added stripping of long paths in the Factorio server log.
|
||||
- Added option to configure maximum number of commands to send in parallel.
|
||||
- Added option to auto start instances on host startup.
|
||||
- Added metric mapping of host and instance ids to their names, allowing them to be displayed by name in queries that join with the mapping.
|
||||
- Added option to configure timeout for exported Prometheus metrics.
|
||||
- Added option to set address to bind HTTP(S) port to.
|
||||
- Added an overview of the installed plugins to the web interface.
|
||||
- Added clusterioctl command to list plugins on the controller.
|
||||
- Added Player Auth plugin for logging in to the web interface by proving the ability to log in to a server as a given user.
|
||||
- Added Node.js based installer to simplify setting up a cluster.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed sslCert and sslPrivKey entries being ignored in the config.
|
||||
- Changed ssl key creation to be done at startup instead of on npm install.
|
||||
- Added error handling during master startup.
|
||||
- Added error handling during controller startup.
|
||||
- Factorio game and rcon port now defaults to a random port above 49151.
|
||||
- Removed unimplemented mods update command.
|
||||
- Fixed rcon password being generated with Math.random().
|
||||
- Added plugins directory to the views path. This makes it possible for
|
||||
plugins to render their own ejs views or pages in their own folders by
|
||||
using paths of the format "pluginName/path/to/page-or-view".
|
||||
- Replaced the per instance copy of the shared Factorio mods with
|
||||
symlinks. On Windows hard links are used instead due to the
|
||||
privileges requirements of symlinks.
|
||||
- Changed the per instance scenario folder to be linked instead of
|
||||
copied on instance creation.
|
||||
- Instance id is no longer derived from the rcon password, instead it's
|
||||
generated upon instance creation and stored in the instance config.
|
||||
- The game port, RCON port and RCON password instance config entries are are
|
||||
now null by default, indicating that a random one will be generated every
|
||||
time the instance is started.
|
||||
- Removed the broken client download command as this would download
|
||||
pre-releases.
|
||||
- Moved the item database and HTTP definitions for /api/place, /api/remove,
|
||||
/api/inventory, /api/inventoryAsObject and the web interface view for the
|
||||
storage page into the clusterioMod plugin. If you disable this plugin
|
||||
then these things will not be available.
|
||||
- Added plugins directory to the views path.
|
||||
This makes it possible for plugins to render their own ejs views or pages in their own folders by using paths of the format "pluginName/path/to/page-or-view".
|
||||
- Replaced the per instance copy of the shared Factorio mods with symlinks.
|
||||
On Windows hard links are used instead due to the privileges requirements of symlinks.
|
||||
- Changed the per instance scenario folder to be linked instead of copied on instance creation.
|
||||
- Instance id is no longer derived from the rcon password, instead it's generated upon instance creation and stored in the instance config.
|
||||
- The game port, RCON port and RCON password instance config entries are are now null by default, indicating that a random one will be generated every time the instance is started.
|
||||
- Removed the broken client download command as this would download pre-releases.
|
||||
- Moved the item database and HTTP definitions for /api/place, /api/remove, /api/inventory, /api/inventoryAsObject and the web interface view for the storage page into the clusterioMod plugin.
|
||||
If you disable this plugin then these things will not be available.
|
||||
- Removed undocumented --port and --rcon-port arguments to client.
|
||||
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable
|
||||
handling in client.
|
||||
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable handling in client.
|
||||
- Removed redundant call to /api/slaves in globalChat plugin.
|
||||
- Removed broken serverManager plugin.
|
||||
- Removed factorio_version from config. The version installed is auto
|
||||
detected and used instead.
|
||||
- Removed factorio_version from config.
|
||||
The version installed is auto detected and used instead.
|
||||
- Removed the playerManager specific command CLI tools/delete_player.js.
|
||||
- Creating an instance, assigning it to a slave, creating a save for an
|
||||
instance and starting an instance is now four separate commands.
|
||||
- Removed oddball limits to slaves.json size.
|
||||
- Moved slave specific and instance specific configuration into their own
|
||||
configuration files.
|
||||
- Creating an instance, assigning it to a host, creating a save for an instance and starting an instance is now four separate commands.
|
||||
- Removed oddball limits to hosts.json size.
|
||||
- Moved host specific and instance specific configuration into their own configuration files.
|
||||
- Removed unused binary option from plugin config.
|
||||
- Removed info and shout command from globalChat plugin
|
||||
- Removed mirrorAllChat and enableCrossServerShout configuration options for
|
||||
globalChat plugin.
|
||||
- Removed UPSdisplay plugin. UPS statistics is exported by the statistics
|
||||
exporter plugin.
|
||||
- Master server now defaults to hosting on https on port 8443.
|
||||
- Removed info and shout command from globalChat plugin - Removed mirrorAllChat and enableCrossServerShout configuration options for globalChat plugin.
|
||||
- Removed UPSdisplay plugin.
|
||||
UPS statistics is exported by the statistics exporter plugin.
|
||||
- Controller now defaults to hosting on https on port 8443.
|
||||
- Renamed client to slave.
|
||||
- Removed rotation of factorio-current.log files.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed lib/authenticate. Breaks playerManager.
|
||||
- Removed lib/authenticate.
|
||||
Breaks playerManager.
|
||||
- The masterIP and masterPort config entries has been merged into masterURL.
|
||||
Breaks discordChat, playerManager, serverSelect, trainTeleports and
|
||||
clusterioModel.
|
||||
- Instance names can no longer be invalid Windows file names.
|
||||
Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel.
|
||||
- Removed config management from the command line and the server manager.
|
||||
- Moved ejs templates into views folder and changed their extension to
|
||||
.ejs. Breaks playerManager.
|
||||
- Moved ejs templates into views folder and changed their extension to .ejs.
|
||||
Breaks playerManager.
|
||||
- Mods are no longer copied from the per instance instanceMods directory.
|
||||
If you need per instance mods you can now place them directly in the mods
|
||||
directory inside the instance folder.
|
||||
If you need per instance mods you can now place them directly in the mods directory inside the instance folder.
|
||||
- Hotpatch scenarios and code loading is no longer compatible with Clusterio.
|
||||
Breaks playerManager, serverSelect, and tranTeleports.
|
||||
- Removed getLua and getCommand from lib/clusterTools. If you need to run
|
||||
more than the most trivial of code in commands use the save patcher and
|
||||
add in a remote interface.
|
||||
- Removed mod uploading and distributing from the HTTP interface. Breaks
|
||||
the old unmaintained and no longer needed factorioClusterioClient.
|
||||
- Removed the remoteCommands plugin and the old runCommand interface. Breaks
|
||||
playerManager and external tools sending commands.
|
||||
- Removed getLua and getCommand from lib/clusterTools.
|
||||
If you need to run more than the most trivial of code in commands use the save patcher and add in a remote interface.
|
||||
- Removed mod uploading and distributing from the HTTP interface.
|
||||
Breaks the old unmaintained and no longer needed factorioClusterioClient.
|
||||
- Removed the remoteCommands plugin and the old runCommand interface.
|
||||
Breaks playerManager and external tools sending commands.
|
||||
- Removed broken serverManager plugin.
|
||||
- Removed fields info, time, rconPort, rconPassword, serverPort, unique,
|
||||
mods, instanceName, playerCount, mac, and meta from the slaves in the
|
||||
slave database.
|
||||
- Removed getInstanceName and lib/clusterTools. Breaks playerManager,
|
||||
and discordChat.
|
||||
- Removed the /api/rconPasswords, and /api/slaves endpoints. Breaks web
|
||||
interface, trainTeleports, and discordChat.
|
||||
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints. Breaks
|
||||
researchSync, and UPSdisplay.
|
||||
- Removed the hello event from the socket connection handshake. Breaks
|
||||
playerManager, trainTeleports, serverSelect, and discordChat
|
||||
- Changed the format of database/slaves.json.
|
||||
- Removed the output file subscription system. Breaks inventoryImports,
|
||||
playerManager, trainTeleports, serverSelect and researchSync.
|
||||
- Removed the factorioOutput hook from instance plugins. The onOutput
|
||||
hook provides parsed output instead.
|
||||
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the hosts in the host database.
|
||||
- Removed getInstanceName and lib/clusterTools.
|
||||
Breaks playerManager, and discordChat.
|
||||
- Removed the /api/rconPasswords, and /api/slaves endpoints.
|
||||
Breaks web interface, trainTeleports, and discordChat.
|
||||
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints.
|
||||
Breaks researchSync, and UPSdisplay.
|
||||
- Removed the hello event from the socket connection handshake.
|
||||
Breaks playerManager, trainTeleports, serverSelect, and discordChat
|
||||
- Changed the format of database/hosts.json.
|
||||
- Removed the output file subscription system.
|
||||
Breaks inventoryImports, playerManager, trainTeleports, serverSelect and researchSync.
|
||||
- Removed the factorioOutput hook from instance plugins.
|
||||
The onOutput hook provides parsed output instead.
|
||||
- Removed the onLoadFinish hook from master plugins.
|
||||
- Moved plugins from the sharedPlugins directory to plugins directory.
|
||||
- Implemented a new plugin system that replaces the old. Breaks all plugins.
|
||||
- Implemented a new plugin system that replaces the old.
|
||||
Breaks all plugins.
|
||||
- Removed express metric http_request_duration_milliseconds metric.
|
||||
- Removed socket.io metrics socket_io_connected, socket_io_connect_total,
|
||||
socket_io_disconnect_total, socket_io_events_received_total,
|
||||
socket_io_events_sent_total, socket_io_recieve_bytes, and
|
||||
socket_io_transmit_bytes,
|
||||
- Removed clusterio_connected_instaces_gauge and added
|
||||
clusterio_master_connected_clients_count in its place.
|
||||
- Renamed clusterio_player_count_gauge to
|
||||
clusterio_statistics_exporter_instance_player_count
|
||||
- Removed clusterio_UPS_gauge and added
|
||||
clusterio_statistics_exporter_instance_game_ticks_total in its place.
|
||||
- Removed socket.io metrics socket_io_connected, socket_io_connect_total, socket_io_disconnect_total, socket_io_events_received_total, socket_io_events_sent_total, socket_io_recieve_bytes, and socket_io_transmit_bytes,
|
||||
- Removed clusterio_connected_instaces_gauge and added clusterio_master_connected_clients_count in its place.
|
||||
- Renamed clusterio_player_count_gauge to clusterio_statistics_exporter_instance_player_count
|
||||
- Removed clusterio_UPS_gauge and added clusterio_statistics_exporter_instance_game_ticks_total in its place.
|
||||
- Renamed clusterio_endpoint_hit_gauge to clusterio_http_enpoint_hits_total
|
||||
- Renamed clusterio_statistics_gauge to
|
||||
clusterio_statistics_exporter_instance_force_flows
|
||||
- Renamed clusterio_statistics_gauge to clusterio_statistics_exporter_instance_force_flows
|
||||
- Renamed clusterio_nn_dole_gauge to clusterio_subspace_storage_nn_dole_gauge.
|
||||
- Renamed clusterio_dole_factor_gauge to
|
||||
clusterio_subspace_storage_dole_factor_gauge.
|
||||
- Renamed clusterio_dole_factor_gauge to clusterio_subspace_storage_dole_factor_gauge.
|
||||
- Renamed clusterio_import_gauge to clusterio_subspace_storage_import_total.
|
||||
- Renamed clusterio_export_gauge to clusterio_subspace_storage_export_total.
|
||||
- Renamed clusterio_master_inventory_gauge to
|
||||
clusterio_subspace_storage_master_inventory.
|
||||
- Removed clusterioMod plugin specific config options logItemTransfers,
|
||||
disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and
|
||||
disableImportsOfEverythingExceptElectricity from the master config.
|
||||
- Removed msBetweenCommands config option. The RCON is instead limited to 5
|
||||
concurrent commands.
|
||||
- Removed allowRemoteCommandExecution config option. Remote commands are
|
||||
always allowed with the move to master managing slaves/instances.
|
||||
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line
|
||||
arguments from the master server.
|
||||
- Implemented a new config system that replaces the old. Breaks all plugins.
|
||||
- Renamed clusterio_master_inventory_gauge to clusterio_subspace_storage_master_inventory.
|
||||
- Removed clusterioMod plugin specific config options logItemTransfers, disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and disableImportsOfEverythingExceptElectricity from the master config.
|
||||
- Removed msBetweenCommands config option.
|
||||
The RCON is instead limited to 5 concurrent commands.
|
||||
- Removed allowRemoteCommandExecution config option.
|
||||
Remote commands are always allowed with the move to controller managing hosts/instances.
|
||||
- Removed `--databaseDirectory`, `--controllerPort`, and `--sslPort` command line arguments from the controller.
|
||||
- Implemented a new config system that replaces the old.
|
||||
Breaks all plugins.
|
||||
- Removed usage of socket.io entirely in favor of a plain WebSocket connection.
|
||||
- Renamed clusterioMod plugin to subspace_storage.
|
||||
- Master no longer creates secret-api-token.txt.
|
||||
- Controller no longer creates secret-api-token.txt.
|
||||
- Removed automatic creation of self-signed TLS certificate.
|
||||
- Node.js versions below 12 are no longer supported.
|
||||
|
||||
|
||||
Version 1.2.4
|
||||
-------------
|
||||
## Version 1.2.4
|
||||
|
||||
- Removed broken remote combinator signaling.
|
||||
- Fixed research sync endlessly updating already researched technologies
|
||||
- Removed obsolete item/fluid statistics from clusterioMod
|
||||
- Removed per mod upload logging when config.uploadModsToMaster disabled
|
||||
- Fixed bcrypt failing to install on windows due to the new version not
|
||||
having Windows binaries.
|
||||
- Fixed bcrypt failing to install on windows due to the new version not having Windows binaries.
|
||||
|
||||
|
||||
Version 1.2.3
|
||||
-------------
|
||||
## Version 1.2.3
|
||||
|
||||
- Disabled uploading mods to the master server by default as this is mostly
|
||||
just a waste of bandwidth with the mod portal being integrated into the
|
||||
game now.
|
||||
- Fixed researchSync breaking with heavily modded games due to the tech tree
|
||||
exceeding the default 100kb limit on JSON payloads.
|
||||
- Fixed the command inventoryImport sends referencing player instead of
|
||||
character, and trying to count the non-existant quick bar slots.
|
||||
- Fixed inventoryImport never receiving the script output after the default
|
||||
mode for reading script output changed to tail mode.
|
||||
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting
|
||||
out of sync when plugins update their scenario mod code. To fix existing
|
||||
games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua
|
||||
over the existing mod-tools.lua in the save's hotpatch folder.
|
||||
- Disabled uploading mods to the master server by default as this is mostly just a waste of bandwidth with the mod portal being integrated into the game now.
|
||||
- Fixed researchSync breaking with heavily modded games due to the tech tree exceeding the default 100kb limit on JSON payloads.
|
||||
- Fixed the command inventoryImport sends referencing player instead of character, and trying to count the non-existant quick bar slots.
|
||||
- Fixed inventoryImport never receiving the script output after the default mode for reading script output changed to tail mode.
|
||||
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting out of sync when plugins update their scenario mod code.
|
||||
To fix existing games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua over the existing mod-tools.lua in the save's hotpatch folder.
|
||||
- Updated the Windows install instructions.
|
||||
|
||||
|
||||
Version 1.2.2
|
||||
-------------
|
||||
## Version 1.2.2
|
||||
|
||||
- Fixed possible crash with modded technologies named the same as a built-in
|
||||
Object prototype property in researchSync.
|
||||
- Fixed progress of a current infinite tech carrying over to the next one
|
||||
when researching it and another node completes it in researchSync.
|
||||
- Fixed progress of a previous infinite tech from another node being applied
|
||||
to the current one in researchSync.
|
||||
- Fixed crash in researchSync when modded technologies are present only on some
|
||||
nodes.
|
||||
- Fixed install failing due to bcrypt version less than 3 not being supported
|
||||
on node v10.
|
||||
- Reordered install instructions to avoid problem with npm creating files owned
|
||||
by root in the home directory.
|
||||
- Swapped curl out with wget in the install instructions as the latter comes
|
||||
pre-installed on Ubuntu.
|
||||
- Fixed possible crash with modded technologies named the same as a built-in Object prototype property in researchSync.
|
||||
- Fixed progress of a current infinite tech carrying over to the next one when researching it and another node completes it in researchSync.
|
||||
- Fixed progress of a previous infinite tech from another node being applied to the current one in researchSync.
|
||||
- Fixed crash in researchSync when modded technologies are present only on some nodes.
|
||||
- Fixed install failing due to bcrypt version less than 3 not being supported on node v10.
|
||||
- Reordered install instructions to avoid problem with npm creating files owned by root in the home directory.
|
||||
- Swapped curl out with wget in the install instructions as the latter comes pre-installed on Ubuntu.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Node.js versions below 10 are no longer supported.
|
||||
|
||||
|
||||
Version 1.2.1
|
||||
-------------
|
||||
## Version 1.2.1
|
||||
|
||||
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking
|
||||
([#229][#229].)
|
||||
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C
|
||||
on Linux ([#217][#217].)
|
||||
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking ([#229][#229]).
|
||||
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C on Linux ([#217][#217]).
|
||||
- Fixed package.json incorrectly reporting the license as ISC.
|
||||
|
||||
[#217]: https://github.com/clusterio/factorioClusterio/issues/217
|
||||
[#229]: https://github.com/clusterio/factorioClusterio/issues/229
|
||||
[#217]: https://github.com/clusterio/clusterio/issues/217
|
||||
[#229]: https://github.com/clusterio/clusterio/issues/229
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
# Warning: This is currently broken as a result of
|
||||
# changes done in the 2.0 refactoring.
|
||||
FROM node:12
|
||||
RUN apt-get update && apt install git curl tar -y
|
||||
RUN mkdir factorioClusterio
|
||||
FROM node:20 as subspace_storage_builder
|
||||
RUN apt update && apt install -y git
|
||||
WORKDIR /
|
||||
RUN git clone https://github.com/clusterio/subspace_storage.git
|
||||
WORKDIR /subspace_storage
|
||||
RUN npm install \
|
||||
&& node build
|
||||
|
||||
RUN git clone -b master https://github.com/clusterio/factorioClusterio.git && cd factorioClusterio && npm install --only=production
|
||||
RUN cd factorioClusterio && curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz
|
||||
FROM node:20 as clusterio_builder
|
||||
RUN apt update \
|
||||
&& apt install -y wget \
|
||||
&& mkdir /clusterio
|
||||
WORKDIR /clusterio
|
||||
RUN wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
# Copy project files into the container
|
||||
COPY . .
|
||||
|
||||
WORKDIR factorioClusterio
|
||||
RUN mkdir instances sharedMods
|
||||
RUN cp config.json.dist config.json
|
||||
RUN pnpm install
|
||||
|
||||
RUN node client manage shared mods add clusterio
|
||||
# Install plugins. This is intended as a reasonable default, enabling plugins to make for fun gameplay.
|
||||
# If you want a different set of plugins, consider using this as the base image for your own.
|
||||
#RUN pnpm install @clusterio/plugin-subspace_storage
|
||||
#RUN npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
LABEL maintainer "Sir3lit@gmail.com"
|
||||
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/mods/
|
||||
|
||||
EXPOSE 8080 34167
|
||||
VOLUME /factorioClusterio/instances
|
||||
VOLUME /factorioClusterio/sharedMods
|
||||
VOLUME /factorioClusterio/sharedPlugins
|
||||
# Build Lua library mod
|
||||
RUN node packages/lib/build_mod --build --source-dir packages/host/lua/clusterio_lib \
|
||||
&& mv dist/* mods/ \
|
||||
&& mkdir temp \
|
||||
&& mkdir temp/test \
|
||||
&& cp mods/ temp/test/ -r \
|
||||
&& ls mods
|
||||
|
||||
CMD MODE="$MODE" node $MODE start $INSTANCE
|
||||
# Remove node_modules
|
||||
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
|
||||
|
||||
FROM frolvlad/alpine-glibc AS clusterio_final
|
||||
|
||||
RUN apk add --update bash nodejs npm
|
||||
|
||||
COPY --from=clusterio_builder /clusterio /clusterio
|
||||
WORKDIR /clusterio
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN pnpm install --production
|
||||
LABEL maintainer "danielv@danielv.no"
|
||||
|
||||
FROM frolvlad/alpine-glibc AS clusterio_testing
|
||||
|
||||
RUN apk add --update bash nodejs npm
|
||||
|
||||
COPY --from=clusterio_builder /clusterio /clusterio
|
||||
WORKDIR /clusterio
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN pnpm install
|
||||
RUN pnpm install chalk semver
|
||||
@@ -4,295 +4,304 @@
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
# factorioClusterio
|
||||
# Clusterio
|
||||
|
||||
Discord for development/support/play: https://discord.gg/5XuDkje
|
||||
|
||||
## Important notice
|
||||
|
||||
This is the development branch for factorioClusterio 2.0 which is currently undergoing heavy
|
||||
restructuring and refactoring. Expect plugins and existing installations to frequently break when
|
||||
using this branch. If you don't want to be an alpha tester for 2.0 please use the stable
|
||||
[1.2.x branch][1.2.x] or
|
||||
[latest stable release](https://github.com/clusterio/factorioClusterio/releases/latest).
|
||||
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
|
||||
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
|
||||
If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
|
||||
|
||||
Installation instructions below are for the unstable master branch. Go to the page for
|
||||
the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
|
||||
|
||||
[1.2.x]: https://github.com/clusterio/factorioClusterio/tree/1.2.x
|
||||
|
||||
### Ways to support me/the project:
|
||||
|
||||
* Contribute with code/documentation. See [Contributing](docs/contributing.md) for how to make pull
|
||||
requests. Always nice to move the project forward.
|
||||
* Contribute with code/documentation.
|
||||
See [Contributing](docs/contributing.md) for how to make pull requests.
|
||||
Always nice to move the project forward.
|
||||
|
||||
* Support me monetarily on [patreon](https://www.patreon.com/danielv123) or paypal: danielv@live.no
|
||||
|
||||
### Table of contents
|
||||
|
||||
* [Introduction & methodology](#introduction)
|
||||
* [Ubuntu setup](#ubuntu-setup)
|
||||
* [Windows setup](#windows-setup)
|
||||
* [Installing Plugins](#installing-plugins)
|
||||
* [Introduction](#introduction)
|
||||
* [Features](#features)
|
||||
* [Plugins](#plugins)
|
||||
* [Installation](#installation)
|
||||
* [Ubuntu setup](#ubuntu-setup)
|
||||
* [Windows setup](#windows-setup)
|
||||
* [MacOS setup](#macos-setup)
|
||||
* [Installing Plugins](#installing-plugins)
|
||||
* [Configure Controller](#configure-controller)
|
||||
* [Managing Factorio mods](#managing-factorio-mods)
|
||||
* [Setting up shared storage](#setting-up-shared-storage)
|
||||
* [Running Clusterio](#running-clusterio)
|
||||
* [Master Server](#master-server)
|
||||
* [Slaves](#slaves)
|
||||
* [Instances](#instances)
|
||||
* [Setting up remote hosts](#setting-up-remote-hosts)
|
||||
* [Setting up clusterioctl](#setting-up-clusterioctl)
|
||||
* [Known issues](#known-issues)
|
||||
* [Common problems](#Common-problems)
|
||||
|
||||
|
||||
## Introduction
|
||||
|
||||
Features:
|
||||
Clusterio is a clustered Factorio server manager that provides the tooling for implementing cross server interactions in Factorio.
|
||||
It was previously best known for implementing cross server transfer and cloud storage of items via teleporter chests.
|
||||
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/subspace_storage).
|
||||
|
||||
- Entities to send/recieve items
|
||||
|
||||
- Cross dimensional storage
|
||||
|
||||
- Sending of liquids
|
||||
|
||||
- Sending of circuit network signals
|
||||
|
||||
- Inventory combinator to display item levels in the cluster (and epoch time)
|
||||
|
||||
- Reporting of graphs and UPS on master interface (Also has extensive Prometheus reporting)
|
||||
|
||||
Optional extras (see [Plugins](#Plugins))
|
||||
|
||||
- Have your inventory synchronize across servers
|
||||
|
||||
- Teleport trans from the border of one world to te next
|
||||
|
||||
- Show in-game chat in discord
|
||||
By itself Clusterio doesn't change the gameplay in any way, you could even use Clusterio to manage completely vanilla Factorio servers.
|
||||
Plugins do the work of modding in the visible changes into the game, see the [Plugins section](#plugins) for ready-made plugins you can install into a Clusterio cluster.
|
||||
|
||||
|
||||
Connection diagram:
|
||||
## Features
|
||||
|
||||

|
||||
- Clustered Factorio server management allowing you manage the running of Factorio servers across a fleet of physical servers from both a web interface and a command line interface.
|
||||
|
||||
There can be any number of clients connected to each slave, and any number of slaves connected to a master but there can only be one master server in each cluster.
|
||||
- User list management for synchronizing in-game admins, whitelisted users, and bans to all the servers in the cluster.
|
||||
|
||||
**How does it work?**
|
||||
- Integrated support for exporting statistics for the whole cluster to Prometheus via a single metrics endpoint.
|
||||
|
||||
Traditional factorio mods have always been limited by the games deterministic design. This gives us a very bug free and predictable game, but doesn't allow us cool stuff such as internet communication.
|
||||
Clusterio exploits one of the games logging features, game.write_file and RCON to communicate between servers. Sending an item from one server to another takes this path:
|
||||
- Extensive plugin support for adding your own cross server features to Factorio using Clusterio's communication backbone.
|
||||
|
||||
1. server1: Chest has stuff in it, write the contents to a file and delete them from the game world
|
||||
|
||||
2. client.js on server1: File has stuff in it, parse and send to the master for storage
|
||||
## Plugins
|
||||
|
||||
3. master: server1 gave us stuff, store it in the storage and write some statistics
|
||||
The heart of Clusterio is its plugin system.
|
||||
Plugins add functionality to Factorio servers, Clusterio itself or both.
|
||||
These are the plugins supported and maintained by the Clusterio developers:
|
||||
|
||||
4. server2: get-chest is empty, write a request to file
|
||||
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
|
||||
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
|
||||
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
|
||||
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
|
||||
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
|
||||
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
|
||||
|
||||
5. client.js on server2: Request file has stuff in it, parse and send a request to master for more items of that type
|
||||
There's also plugins developed and maintained by the community:
|
||||
|
||||
6. master: server2 asked for stuff, check if we have enough and how much demand there is, then send however much is appropriate back
|
||||
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge) (@hornwitser/discord_bridge): Bridges chat between instances and Discord.
|
||||
By Hornwitser.
|
||||
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other servers in the cluster.
|
||||
Originally by Godmave, ported to 2.0 by Hornwitser.
|
||||
|
||||
7. client.js on server2: We were allowed to import x of item y, run command /c remote.call("clusterio", "importMany", "{'copper-plate':120}")
|
||||
Want to make your own plugin?
|
||||
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
||||
|
||||
This process works the same for both items and liquids, independent on what mods are used. Yes, modded items are fully supported.
|
||||
|
||||
Clusterio can also do a few other neat things, such as giving you access to epoch time, syncing player inventories between servers, keeping track of playtime (playerManager plugin), teleporting trains between servers (trainTeleports) and exporting tons of factorio related statistics to Prometheus for graphing in grafana.
|
||||
## Installation
|
||||
|
||||
## Ubuntu setup
|
||||
Clusterio runs on Node.js v18 and up, it's distributed via npm and comes with a guided install script.
|
||||
If you already have a recent Node.js installed, you can set it up in a new directory with:
|
||||
|
||||
**Warning**: These instructions are for the unstable master version and is not
|
||||
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
|
||||
npm init "@clusterio"
|
||||
|
||||
Clusterio runs on Node.js v12 and up, v11.13.0+ and v10.16.0+. Node.js itself is not
|
||||
supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
|
||||
Otherwise see below for OS specific instructions.
|
||||
|
||||
Master and all slaves:
|
||||
|
||||
wget -qO - https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init -y
|
||||
npm install @clusterio/master @clusterio/slave @clusterio/ctl
|
||||
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
|
||||
tar -xf factorio.tar.gz
|
||||
### Ubuntu setup
|
||||
|
||||
1. Install Node.js v18 or higher.
|
||||
Note the version of Node.js provided by the Ubuntu/debian repos are too old and you will have to use the nodesource PPA
|
||||
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
|
||||
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init "@clusterio"
|
||||
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
3. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
|
||||
|
||||
downloads and installs nodejs, git and clusterio. To specify a version, change "latest" in the link to a version number like 0.14.21.
|
||||
|
||||
**Ubuntu with Docker**
|
||||
|
||||
The Docker support for Clusterio is curently broken. If you need it
|
||||
open an issue about it.
|
||||
The Docker support for Clusterio is currently broken.
|
||||
If you need it open an issue about it.
|
||||
|
||||
<!--
|
||||
Clusterio has *very* limited support for using docker.
|
||||
|
||||
sudo docker build -t clusterio --no-cache --force-rm factorioClusterio
|
||||
sudo docker build -t clusterio --no-cache --force-rm clusterio
|
||||
|
||||
sudo docker run --name master -e MODE=master -p 1234:8080 -d -it --restart=unless-stopped danielvestol/clusterio
|
||||
sudo docker run --name controller -e MODE=controller -p 1234:8080 -d -it --restart=unless-stopped danielvestol/clusterio
|
||||
|
||||
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/factorioClusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
|
||||
sudo docker run --name host -e MODE=host -e INSTANCE=world1 -v /srv/clusterio/instances:/clusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
|
||||
|
||||
The -v flag is used to specify the instance directory. Your instances (save files etc) will be stored there.
|
||||
The -v flag is used to specify the instance directory.
|
||||
Your instances (save files etc) will be stored there.
|
||||
-->
|
||||
|
||||
## Windows setup
|
||||
### Windows setup
|
||||
|
||||
**Warning**: These instructions are for the unstable master version and is not
|
||||
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
|
||||
1. Download and install the latest LTS release from http://nodejs.org.
|
||||
|
||||
**Requirements**
|
||||
2. Create a new empty directory for the installation and navigate into it.
|
||||
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
|
||||
|
||||
download and install nodeJS 12 from http://nodejs.org. Clusterio runs on Node.js v12 and
|
||||
up, v11.13.0+ and v10.16.0+.
|
||||
npm init "@clusterio"
|
||||
|
||||
**Master**
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
1. Open PowerShell or Command prompt in the directory you want to install to and run the following commands.
|
||||
3. If you chose to use local factorio directory for the Factorio installation, download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
|
||||
|
||||
|
||||
### MacOS Setup
|
||||
|
||||
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
|
||||
|
||||
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
|
||||
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init -y
|
||||
npm install @clusterio/master @clusterio/slave @clusterio/ctl
|
||||
npm init "@clusterio"
|
||||
|
||||
2. Obtain Factorio by either of these two methods:
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
- Via the stand alone version on from their website
|
||||
|
||||
1. Create a new folder named "factorio" in the the factorioClusterio
|
||||
folder.
|
||||
|
||||
2. Download the MS Windows (64-bit zip package) from
|
||||
https://www.factorio.com/download .
|
||||
|
||||
3. Open the zip file and drag the folder called "Factorio_x.y.z" into
|
||||
the factorio folder created in step 1.
|
||||
|
||||
- Via steam installation
|
||||
|
||||
1. Create a new folder named "factorio" in the the factorioClusterio
|
||||
folder.
|
||||
|
||||
2. Locate the game files by right clicking the game in steam,
|
||||
selecting properties, then Local Files, then Browse local files.
|
||||
|
||||
3. Go to the parent folder of the folder that Steam opened and copy
|
||||
the Factorio folder into the factorio folder created in step 1.
|
||||
3. If you chose to use local factorio directory for the Factorio installation, you will need to obtain and copy a mac version of Factorio and unpack it to the `factorio` folder in your clusterio installation folder.
|
||||
|
||||
|
||||
## Installing Plugins
|
||||
### Installing Plugins
|
||||
|
||||
Installing plugins to make them work with Clusterio consists of two
|
||||
steps. First install the package via npm, for example
|
||||
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
|
||||
Installing plugins that are not offered by the installer consists of two steps.
|
||||
First install the package the plugin is provided by via npm, for example
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
|
||||
Then tell clusterio that this plugin exists by adding it as a plugin.
|
||||
|
||||
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
|
||||
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
This adds it to the default `plugin-list.json` file which in the shared
|
||||
folder setup is loaded by master, slave and ctl. If you have slaves or
|
||||
ctl installed on separate computers (or directories) then you need to
|
||||
repeat the plugin install process for all of them. The clusteriomaster,
|
||||
clusterioslave and clusterioctl commands has the plugin sub-command
|
||||
so you do not need to install clusteriomaster to add plugins.
|
||||
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
|
||||
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
|
||||
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller to add plugins, instead use the clusterio command you have available.
|
||||
|
||||
For development purposes the `plugin add` command supports adding
|
||||
plugins by the absolute path to them, or a relative path that must start
|
||||
with either . or .. (which will then be resolved to an absolute path).
|
||||
For development purposes the `plugin add` command supports adding plugins by the absolute path to them, or a relative path that must start with either . or .. (which will then be resolved to an absolute path).
|
||||
|
||||
|
||||
## Configure Controller
|
||||
|
||||
By default the controller will listen for HTTP on port 8080.
|
||||
You can change the port used with the command
|
||||
|
||||
npx clusteriocontroller config set controller.http_port 1234
|
||||
|
||||
When changing the port you will also need to change the address hosts connect with.
|
||||
For the standalone installation mode you can use
|
||||
|
||||
npx clusteriohost config set host.controller_url http://localhost:1234/
|
||||
|
||||
If you plan to make your cluster available externally set the address
|
||||
that it will be accessible under with, for example
|
||||
|
||||
npx clusteriocontroller config set controller.external_address http://203.0.113.4:1234/
|
||||
|
||||
Change the url to reflect the IP, protocol, and port the controller is accessible under, dns names are also supported.
|
||||
If you're planning on making the controller accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
|
||||
|
||||
You can list the config of the controller with the `npx clusteriocontroller config list` command.
|
||||
See the [readme for @clusterio/controller](/packages/controller/README.md) for more information.
|
||||
|
||||
|
||||
## Managing Factorio mods
|
||||
|
||||
Clusterio currently only provides a manual method for running mods on servers by copying them either to each instance's individual `mods` folder, or by copying them into a host installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that host if the file doesn't already exist in the `mods` folder for that instance.
|
||||
Mods are copied from `sharedMods` on Windows and symlinked on Linux, this means that when mods are removed from `sharedMods` the mods will also be removed from the instance's `mods` folder on Linux if it got there by being linked by Clusterio, but not on Windows.
|
||||
|
||||
For managing the mod settings, the `mod-settings.dat` and `mod-list.json` files will also be linked/copied over if present in `sharedMods`, but since Factorio creates these files automatically it might be necessary to delete them from each instance's `mods` folder for changes to them in `sharedMods` to take effect.
|
||||
|
||||
Better remote management of mods is a planned feature.
|
||||
|
||||
|
||||
## Setting up shared storage
|
||||
|
||||
The chests teleporting items to and from the shared storage on the controller has been moved into the Subspace Storage mod and plugin.
|
||||
To get this to work you will need to install the Subspace Storage plugin and copy the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) into the `sharedMods` folder on each host installation.
|
||||
|
||||
Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), this was for the old 1.2.x version of Clusterio and is not compatible with Clusterio 2.0.
|
||||
|
||||
|
||||
## Running Clusterio
|
||||
|
||||
After following the installation instructions you can use the following
|
||||
commands to run Clusterio.
|
||||
After completing the installation start up the controller and at least one host separately.
|
||||
The installer provides the `run-controller` and `run-host` scripts to make this simple.
|
||||
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
|
||||
|
||||
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start.
|
||||
|
||||
### Running via Systemd
|
||||
|
||||
The install script creates systemd service scripts for clusteriocontroller and clusteriohost (if applicable) to start up as the user than ran the installer.
|
||||
If you have copied these files over to `/etc/systemd/system/` then you can startup Clusterio as a background service using:
|
||||
|
||||
#### To start as a service run:
|
||||
|
||||
sudo systemctl start clusteriocontroller # for the controller
|
||||
sudo systemctl start clusteriohost # for each physical server intended to host Factorio servers
|
||||
|
||||
#### To automatically get it to start on boot:
|
||||
|
||||
sudo systemctl enable clusteriocontroller # for the controller
|
||||
sudo systemctl enable clusteriohost # for each physical server intended to host Factorio servers
|
||||
|
||||
|
||||
### Setting up an admin account
|
||||
## Setting up remote hosts
|
||||
|
||||
Before you can manage the cluster you need to bootstrap an admin account
|
||||
for it. Replace `<username>` with your Factorio username (do not make
|
||||
up a new username here).
|
||||
|
||||
npx clusteriomaster bootstrap create-admin <username>
|
||||
npx clusteriomaster bootstrap create-ctl-config <username>
|
||||
|
||||
The first command creates a user account with the given name and
|
||||
promotes it to a cluster admin. The second one sets up a
|
||||
`config-control.json` config for clusterioctl to connect to the master
|
||||
server under the given user account.
|
||||
Run the installer as described in the installation section and choose "Host only" as the operating mode to install, it'll ask for a controller URL and an authentication token.
|
||||
The URL is the same as what is needed to connect to the web interface, and the authentication token can be generated on the Hosts page in the web interface.
|
||||
Once you start up the host it should show up in the Hosts list and be available for assigning and running instances on.
|
||||
|
||||
|
||||
### Running the master Server
|
||||
## Setting up clusterioctl
|
||||
|
||||
It's necessary to run the master server in order for anything to work.
|
||||
Once you've completed the setup run the following command to start it
|
||||
up:
|
||||
There's a command line interface available for Clusterio which is installed separately with the same installer as for the controller and host.
|
||||
Run the installer as described in the installation section and choose "Ctl only" as the operating mode to install, you can do this in the same directory as you have installed other Clusterio component(s) to.
|
||||
The installer will ask for a controller URL and an authentication token, these are the same as you would use to connect to the web interface.
|
||||
If you want to use a different user for the command line interface, you can generate an authentication token for an existing user with
|
||||
|
||||
npx clusteriomaster run
|
||||
npx clusteriocontroller bootstrap generate-user-token <username>
|
||||
|
||||
|
||||
### Slaves
|
||||
## Known Issues
|
||||
|
||||
Slaves connect to the master server and are managed remotely from the
|
||||
master server. In order for slaves to connect to the master server they
|
||||
need a valid authentication token, you can create a slave config with
|
||||
a valid token with the following command.
|
||||
|
||||
npx clusterioctl slave create-config --name Local --generate-token
|
||||
|
||||
This will write a new `config-slave.json` file in the current directory
|
||||
(you can change the location with the `--output` option) with the name,
|
||||
token and url to connect to the master server with. If you are making
|
||||
the config for a remote slave you will need to have set the
|
||||
`master.external_address` option to the URL the master server can be
|
||||
reached on.
|
||||
|
||||
You can list the config of a slave on the slave itself with the `npx
|
||||
clusterioslave config list` command. Use `npx clusterioslave config
|
||||
--help` for more information.
|
||||
|
||||
Once the config is set up run the slave with
|
||||
|
||||
npx clusterioslave run
|
||||
|
||||
|
||||
### Instances
|
||||
|
||||
Instances are created, managed and started from the master server. For
|
||||
now the only interface available is the `clusterioctl` command line tool
|
||||
included in Clusterio. You can run this tool from any slave, or the
|
||||
master server without having to set up a config, if you want to manage
|
||||
the cluster from somewhere else you will need to set the
|
||||
`control.master_url` and `control.master_token` options with the `node
|
||||
clusterioctl control-config set` command:
|
||||
|
||||
The basic operations to start a new instance is the following
|
||||
|
||||
npx clusterioctl instance create "My instance"
|
||||
npx clusterioctl instance assign "My instance" "Local"
|
||||
npx clusterioctl instance start "My instance"
|
||||
|
||||
The first line creates the instance configuration on the master server.
|
||||
The second assigns the instance to a slave which creates the instance
|
||||
directory and files needed to run the instance on the given slave. The
|
||||
third line starts the instance, which creates a new save if there are no
|
||||
save games present.
|
||||
|
||||
There are many more commands available with clusterioctl. See
|
||||
`npx clusterioctl --help` for a full list of them.
|
||||
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
|
||||
This should be ignored as it does not indicate an error occured.
|
||||
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
|
||||
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
|
||||
- In the mod pack view of the web interface the "base" mod is displayed with an error complaining about the mod being missing from the controller storage.
|
||||
This is due to the "base" mod currently not being connectly handled as a built-in mod to Factorio in the web interface, this error message should be ignored.
|
||||
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
|
||||
To workaround this find the id of the host in the host config and generate a new host token with the same id.
|
||||
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
|
||||
- After migrating from Alpha 13 to Alpha 14 you get kicked out from the web interface due to the master to controller rename.
|
||||
If you don't have the authentication token readily available you can manually migrate the token by opening the Inspect/Developer Tools on the page and running the following piece of JavaScript in the Console:
|
||||
```
|
||||
localStorage.controller_token = localStorage.master_token
|
||||
```
|
||||
|
||||
|
||||
## Common problems
|
||||
|
||||
### EACCESS [...] LISTEN 443
|
||||
|
||||
Some systems don't let non root processes listen to ports below 1000. Either run with `sudo` or change config.json to use higher port numbers.
|
||||
Some systems don't let non root processes listen to ports below 1000.
|
||||
Either run with `sudo` or change config.json to use higher port numbers.
|
||||
|
||||
According to [this link](https://askubuntu.com/questions/839520/open-port-443-for-a-node-web-app) if you manually installed node.js following the above instructions, you may need to run the following command to fix this issue:
|
||||
|
||||
sudo setcap 'cap_net_bind_service=+ep' $(readlink -f $(which node))
|
||||
|
||||
### Portforwarding doesn't work on the master server when running under WSL
|
||||
### Port forwarding doesn't work on the controller when running under WSL
|
||||
|
||||
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip. This is also true when you correctly port-forwarded the correct ports. Even when routing this server through nginx in WSL, the issue persists. Then, on a hunch, I tried to run nginx from windows itself and found that this DID work. It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
|
||||
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip.
|
||||
This is also true when you correctly port-forwarded the correct ports.
|
||||
Even when routing this server through nginx in WSL, the issue persists.
|
||||
Then, on a hunch, I tried to run nginx from windows itself and found that this DID work.
|
||||
It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
|
||||
|
||||
TLDR: the tested fix is:
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
unit_test:
|
||||
image: clusterio
|
||||
build: .
|
||||
command: npm run test
|
||||
@@ -0,0 +1,9 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
controller:
|
||||
build: .
|
||||
command: npx clusteriocontroller run
|
||||
host:
|
||||
build: .
|
||||
command: npx clusteriohost run
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1000px" height="840px" viewBox="-0.5 -0.5 1000 840" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="40" y="550" width="320" height="250" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 547px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="42" y="547" fill="#000000" font-family="Helvetica" font-size="12px">connected</text></switch></g><rect x="780" y="40" width="180" height="150" fill="#cedbee" stroke="none" pointer-events="all"/><path d="M 300 750 L 300 730 L 440 730 L 440 140 L 376.37 140" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 371.12 140 L 378.12 136.5 L 376.37 140 L 378.12 143.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 723px; margin-left: 346px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">yes</div></div></div></foreignObject><text x="346" y="726" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">yes</text></switch></g><rect x="520" y="490" width="440" height="310" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 438px; height: 1px; padding-top: 487px; margin-left: 522px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="522" y="487" fill="#000000" font-family="Helvetica" font-size="12px">resuming</text></switch></g><rect x="40" y="210" width="380" height="300" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 378px; height: 1px; padding-top: 207px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connecting</div></div></div></foreignObject><text x="42" y="207" fill="#000000" font-family="Helvetica" font-size="12px">connecting</text></switch></g><path d="M 120 150 L 120 223.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 120 228.88 L 116.5 221.88 L 120 223.63 L 123.5 221.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 191px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; coLine truncated
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="919px" height="620px" viewBox="-0.5 -0.5 919 620" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="40" y="260" width="320" height="300" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 257px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="42" y="257" fill="#000000" font-family="Helvetica" font-size="12px">connected</text></switch></g><rect x="700" y="40" width="180" height="150" fill="#cedbee" stroke="none" pointer-events="all"/><path d="M 280 390 L 280 370 L 380 370 L 380 130 L 346.37 130" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 341.12 130 L 348.12 126.5 L 346.37 130 L 348.12 133.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 360px; margin-left: 291px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">yes</div></div></div></foreignObject><text x="291" y="363" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">yes</text></switch></g><rect x="420" y="190" width="160" height="160" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 158px; height: 1px; padding-top: 187px; margin-left: 422px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="422" y="187" fill="#000000" font-family="Helvetica" font-size="12px">resuming</text></switch></g><path d="M 120 140 L 120 273.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 120 278.88 L 116.5 271.88 L 120 273.63 L 123.5 271.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 194px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">ready()</div></div></div></foreignObject><text x="151" y="197" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">ready()</text></switch></g><rect x="40" y="80" width="320" height="80" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 77px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #00Line truncated
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -1,20 +0,0 @@
|
||||
digraph "Client Connection Flow" {
|
||||
node [shape=box];
|
||||
graph [newrank=true];
|
||||
|
||||
start [label="Start"];
|
||||
wait_hello [label="Wait for hello"];
|
||||
new_session [label="New session"];
|
||||
wait_ready [label="Wait for ready"];
|
||||
connected [label="Connected"];
|
||||
abort [label="Abort"];
|
||||
lost_connection [label="Connection lost or\nunexpectedly closed\nwhile connecting" style="dotted"];
|
||||
|
||||
|
||||
lost_connection -> start [label="Wait random delay"];
|
||||
start -> wait_hello [label="Connect to /api/socket"];
|
||||
wait_hello -> new_session [label="Receive hello"];
|
||||
new_session -> wait_ready [label="Send register_*"];
|
||||
wait_ready -> connected [label="Receive ready"];
|
||||
wait_ready -> abort [label="Connection closed with 4003"];
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<mxfile host="Electron" modified="2021-08-30T18:05:06.237Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/14.9.6 Chrome/89.0.4389.128 Electron/12.0.16 Safari/537.36" etag="uzcLt7Oh4qtDsMY9WSdT" compressed="false" version="14.9.6" type="device">
|
||||
<diagram id="GSbHU_PhJe-l3NQpc0X3" name="Connection Flow">
|
||||
<mxGraphModel dx="710" dy="734" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="760" pageHeight="630" background="#EEF3FA" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="GTJXd52-XhRZD3rAr2nm-0" />
|
||||
<mxCell id="GTJXd52-XhRZD3rAr2nm-1" parent="GTJXd52-XhRZD3rAr2nm-0" />
|
||||
<mxCell id="2r3fOvGVNr9_K5ZBoRis-18" value="Client" style="text;html=1;align=left;verticalAlign=middle;resizable=1;points=[];autosize=0;strokeColor=none;fontStyle=1" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="440" y="80" width="145" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-0">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="260" y="320.02" />
|
||||
<mxPoint x="260" y="320.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-5" value="receive<br>resume" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-1">
|
||||
<mxGeometry x="0.086" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="-6" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-2">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="180" y="320.02" />
|
||||
<mxPoint x="180" y="320.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-26" value="receive<br>register" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-3">
|
||||
<mxGeometry x="-0.2268" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="2" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-13">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="40" y="285.02" />
|
||||
<mxPoint x="40" y="580.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-27" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-14">
|
||||
<mxGeometry x="-0.6341" y="-2" relative="1" as="geometry">
|
||||
<mxPoint x="28" y="17" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="2r3fOvGVNr9_K5ZBoRis-28" value="Wait for handshake" style="rounded=0;whiteSpace=wrap;html=1;strokeWidth=2;" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="160" y="270.02000000000004" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="HutjLCx0ygpoBEJLSh69-0" value="Server" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fontStyle=1" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="40" y="80" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_PUvpjEMIo1z1dIbIUzw-0" value="<p><span>Connection Flow</span><br></p>" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontStyle=0;fontSize=20;" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="40" y="30" width="230" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fGsjKHM69yN9R0Hf5M05-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="fGsjKHM69yN9R0Hf5M05-0" target="IKjVovVhkWe_WipwIJL3-7">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="220" y="180" />
|
||||
<mxPoint x="180" y="180" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="fGsjKHM69yN9R0Hf5M05-0" value="WebSocket Open" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="160" y="119.98" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-0" target="IKjVovVhkWe_WipwIJL3-9">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="300" y="300.02" />
|
||||
<mxPoint x="300" y="300.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-6" value="bad session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingLeft=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-4">
|
||||
<mxGeometry x="-0.429" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="29" y="-4" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-0" target="IKjVovVhkWe_WipwIJL3-19">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-29" value="valid" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-20">
|
||||
<mxGeometry x="-0.1944" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="18" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-0" value="Validate resume" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="230" y="340.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-2" target="IKjVovVhkWe_WipwIJL3-18">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-28" value="valid" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-21">
|
||||
<mxGeometry x="-0.2778" relative="1" as="geometry">
|
||||
<mxPoint x="20" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-2" target="IKjVovVhkWe_WipwIJL3-13">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="60" y="355.02" />
|
||||
<mxPoint x="60" y="560.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-30" value="bad token" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-24">
|
||||
<mxGeometry x="-0.8093" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="29" y="23" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-2" value="Validate register" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="90" y="340.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-7" target="2r3fOvGVNr9_K5ZBoRis-28">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="180" y="260.02" />
|
||||
<mxPoint x="180" y="260.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-7" value="Send hello" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="90" y="199.96" width="120" height="30.02" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-9" target="2r3fOvGVNr9_K5ZBoRis-28">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="260" y="260.02" />
|
||||
<mxPoint x="260" y="260.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-9" value="Send invalidate" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="230" y="199.96" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-13" value="Close connection" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="160" y="550.02" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-15" value="Connected" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="160" y="480.02" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-18" target="IKjVovVhkWe_WipwIJL3-15">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="180" y="470" />
|
||||
<mxPoint x="180" y="470" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-18" value="Send ready" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="90" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-19" target="IKjVovVhkWe_WipwIJL3-15">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="260" y="470" />
|
||||
<mxPoint x="260" y="470" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-19" value="Send continue" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="230" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-44">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="630" y="240.02" />
|
||||
<mxPoint x="630" y="240.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-32" value="previous<br>session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=8;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-31">
|
||||
<mxGeometry x="0.086" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="-2" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-49">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="550" y="250.02" />
|
||||
<mxPoint x="550" y="250.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-34" value="no<br>session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=8;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-33">
|
||||
<mxGeometry x="-0.2268" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="5" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-54">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="440" y="215.02" />
|
||||
<mxPoint x="440" y="570.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-36" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-35">
|
||||
<mxGeometry x="-0.6341" y="-2" relative="1" as="geometry">
|
||||
<mxPoint x="35" y="-23" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-37" value="Wait for hello" style="rounded=0;whiteSpace=wrap;html=1;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="199.96000000000006" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-60" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-39" target="IKjVovVhkWe_WipwIJL3-37">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-39" value="WebSocket Open" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="119.98" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-65" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-44" target="IKjVovVhkWe_WipwIJL3-61">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="660" y="360.02" />
|
||||
<mxPoint x="660" y="360.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-44" value="Send resume" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="600" y="270.0199999999999" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-68" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-49" target="IKjVovVhkWe_WipwIJL3-67">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-49" value="Send register" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="460" y="270.0199999999999" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-54" value="Close connection" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="550.02" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-55" value="Connected" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="480.0199999999999" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-64" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-62">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="630" y="390.02" />
|
||||
<mxPoint x="630" y="390.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-73" value="receive<br>invalidate" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-64">
|
||||
<mxGeometry x="0.2133" relative="1" as="geometry">
|
||||
<mxPoint x="-30" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-54">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="570.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-74" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;rotation=90;spacingTop=7;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-70">
|
||||
<mxGeometry x="-0.7644" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-12" y="29" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-71" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-55">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="630" y="450.02" />
|
||||
<mxPoint x="630" y="450.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-75" value="receive<br>continue" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];spacingRight=4;labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-71">
|
||||
<mxGeometry x="-0.3167" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-19" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-61" value="Wait for continue" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="600" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-63" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-62" target="IKjVovVhkWe_WipwIJL3-49">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="550" y="320.02" />
|
||||
<mxPoint x="550" y="320.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-62" value="Invalidate session" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="340.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-67" target="IKjVovVhkWe_WipwIJL3-54">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="470" y="560.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-77" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];rotation=90;labelBackgroundColor=none;spacingBottom=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-69">
|
||||
<mxGeometry x="-0.8148" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="12" y="33" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-72" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-67" target="IKjVovVhkWe_WipwIJL3-55">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="550" y="470.02" />
|
||||
<mxPoint x="550" y="470.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-76" value="receive<br>ready" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-72">
|
||||
<mxGeometry x="-0.2389" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-22" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-67" value="Wait for ready" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="460" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -1,99 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.3 (0)
|
||||
-->
|
||||
<!-- Title: Client Connection Flow Pages: 1 -->
|
||||
<svg width="371pt" height="496pt"
|
||||
viewBox="0.00 0.00 371.00 496.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 492)">
|
||||
<title>Client Connection Flow</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-492 367,-492 367,4 -4,4"/>
|
||||
<!-- start -->
|
||||
<g id="node1" class="node">
|
||||
<title>start</title>
|
||||
<polygon fill="none" stroke="black" points="135,-384 81,-384 81,-348 135,-348 135,-384"/>
|
||||
<text text-anchor="middle" x="108" y="-362.3" font-family="Times,serif" font-size="14.00">Start</text>
|
||||
</g>
|
||||
<!-- wait_hello -->
|
||||
<g id="node2" class="node">
|
||||
<title>wait_hello</title>
|
||||
<polygon fill="none" stroke="black" points="164.5,-297 51.5,-297 51.5,-261 164.5,-261 164.5,-297"/>
|
||||
<text text-anchor="middle" x="108" y="-275.3" font-family="Times,serif" font-size="14.00">Wait for hello</text>
|
||||
</g>
|
||||
<!-- start->wait_hello -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>start->wait_hello</title>
|
||||
<path fill="none" stroke="black" d="M108,-347.8C108,-336.16 108,-320.55 108,-307.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-307.18 108,-297.18 104.5,-307.18 111.5,-307.18"/>
|
||||
<text text-anchor="middle" x="187.5" y="-318.8" font-family="Times,serif" font-size="14.00">Connect to /api/socket</text>
|
||||
</g>
|
||||
<!-- new_session -->
|
||||
<g id="node3" class="node">
|
||||
<title>new_session</title>
|
||||
<polygon fill="none" stroke="black" points="161,-210 55,-210 55,-174 161,-174 161,-210"/>
|
||||
<text text-anchor="middle" x="108" y="-188.3" font-family="Times,serif" font-size="14.00">New session</text>
|
||||
</g>
|
||||
<!-- wait_hello->new_session -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>wait_hello->new_session</title>
|
||||
<path fill="none" stroke="black" d="M108,-260.8C108,-249.16 108,-233.55 108,-220.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-220.18 108,-210.18 104.5,-220.18 111.5,-220.18"/>
|
||||
<text text-anchor="middle" x="155.5" y="-231.8" font-family="Times,serif" font-size="14.00">Receive hello</text>
|
||||
</g>
|
||||
<!-- wait_ready -->
|
||||
<g id="node4" class="node">
|
||||
<title>wait_ready</title>
|
||||
<polygon fill="none" stroke="black" points="167.5,-123 48.5,-123 48.5,-87 167.5,-87 167.5,-123"/>
|
||||
<text text-anchor="middle" x="108" y="-101.3" font-family="Times,serif" font-size="14.00">Wait for ready</text>
|
||||
</g>
|
||||
<!-- new_session->wait_ready -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>new_session->wait_ready</title>
|
||||
<path fill="none" stroke="black" d="M108,-173.8C108,-162.16 108,-146.55 108,-133.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-133.18 108,-123.18 104.5,-133.18 111.5,-133.18"/>
|
||||
<text text-anchor="middle" x="164" y="-144.8" font-family="Times,serif" font-size="14.00">Send register_*</text>
|
||||
</g>
|
||||
<!-- connected -->
|
||||
<g id="node5" class="node">
|
||||
<title>connected</title>
|
||||
<polygon fill="none" stroke="black" points="92,-36 0,-36 0,0 92,0 92,-36"/>
|
||||
<text text-anchor="middle" x="46" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
|
||||
</g>
|
||||
<!-- wait_ready->connected -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>wait_ready->connected</title>
|
||||
<path fill="none" stroke="black" d="M64.78,-86.86C57.71,-82.13 51.29,-76.25 47,-69 43.04,-62.32 41.73,-54.22 41.69,-46.49"/>
|
||||
<polygon fill="black" stroke="black" points="45.19,-46.55 42.31,-36.36 38.21,-46.13 45.19,-46.55"/>
|
||||
<text text-anchor="middle" x="97.5" y="-57.8" font-family="Times,serif" font-size="14.00">Receive ready</text>
|
||||
</g>
|
||||
<!-- abort -->
|
||||
<g id="node6" class="node">
|
||||
<title>abort</title>
|
||||
<polygon fill="none" stroke="black" points="198,-36 142,-36 142,0 198,0 198,-36"/>
|
||||
<text text-anchor="middle" x="170" y="-14.3" font-family="Times,serif" font-size="14.00">Abort</text>
|
||||
</g>
|
||||
<!-- wait_ready->abort -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>wait_ready->abort</title>
|
||||
<path fill="none" stroke="black" d="M131.28,-86.66C137.25,-81.45 143.3,-75.4 148,-69 153.19,-61.93 157.5,-53.45 160.88,-45.5"/>
|
||||
<polygon fill="black" stroke="black" points="164.14,-46.78 164.56,-36.19 157.63,-44.21 164.14,-46.78"/>
|
||||
<text text-anchor="middle" x="259.5" y="-57.8" font-family="Times,serif" font-size="14.00">Connection closed with 4003</text>
|
||||
</g>
|
||||
<!-- lost_connection -->
|
||||
<g id="node7" class="node">
|
||||
<title>lost_connection</title>
|
||||
<polygon fill="none" stroke="black" stroke-dasharray="1,5" points="188.5,-488 27.5,-488 27.5,-435 188.5,-435 188.5,-488"/>
|
||||
<text text-anchor="middle" x="108" y="-472.8" font-family="Times,serif" font-size="14.00">Connection lost or</text>
|
||||
<text text-anchor="middle" x="108" y="-457.8" font-family="Times,serif" font-size="14.00">unexpectedly closed</text>
|
||||
<text text-anchor="middle" x="108" y="-442.8" font-family="Times,serif" font-size="14.00">while connecting</text>
|
||||
</g>
|
||||
<!-- lost_connection->start -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>lost_connection->start</title>
|
||||
<path fill="none" stroke="black" d="M108,-434.95C108,-422.36 108,-407.17 108,-394.35"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-394.24 108,-384.24 104.5,-394.24 111.5,-394.24"/>
|
||||
<text text-anchor="middle" x="175.5" y="-405.8" font-family="Times,serif" font-size="14.00">Wait random delay</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="760px" height="630px" viewBox="-0.5 -0.5 760 630" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="440" y="80" width="145" height="20" fill="none" stroke="none" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 90px; margin-left: 442px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; font-weight: bold; white-space: nowrap; ">Client</div></div></div></foreignObject><text x="442" y="94" fill="#000000" font-family="Helvetica" font-size="12px" font-weight="bold">Client</text></switch></g><path d="M 260 300.02 L 260 320 L 260 333.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 260 338.9 L 256.5 331.9 L 260 333.65 L 263.5 331.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 316px; margin-left: 238px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">receive<br />resume</div></div></div></foreignObject><text x="238" y="319" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">receive...</text></switch></g><path d="M 180 300.02 L 180 320 L 180 333.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 180 338.9 L 176.5 331.9 L 180 333.65 L 183.5 331.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 318px; margin-left: 158px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">receive<br />register</div></div></div></foreignObject><text x="158" y="322" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">receive...</text></switch></g><path d="M 160 285 L 40 285 L 40 580 L 153.63 580" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 158.88 580 L 151.88 583.5 L 153.63 580 L 151.88 576.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 300px; margin-left: 91px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">bad handshake<br />timeout</div></div></div></foreignObject><text x="91" y="304" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">bad handshake...</text></switch></g><rect x="160" y="270.02" width="120" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 285px; margin-left: 161px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "Line truncated
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="800px" height="440px" viewBox="-0.5 -0.5 800 440" style="background-color: rgb(238, 243, 250);"><defs/><g><path d="M 170 365.02 L 170 385 L 290 385 L 290 371.39" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 290 366.14 L 293.5 373.14 L 290 371.39 L 286.5 373.14 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 110 350 L 40 350 L 40 130 L 103.63 130" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 108.88 130 L 101.88 133.5 L 103.63 130 L 101.88 126.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="110" y="335.02" width="100" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 350px; margin-left: 111px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="160" y="354" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">connected</text></switch></g><path d="M 290 335.02 L 290 315 L 180 315 L 180 328.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 180 333.9 L 176.5 326.9 L 180 328.65 L 183.5 326.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 350 335.02 L 350 135 L 216.37 135" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 211.12 135 L 218.12 131.5 L 216.37 135 L 218.12 138.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 320 335.02 L 320 235 L 216.37 235" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 211.12 235 L 218.12 231.5 L 216.37 235 L 218.12 238.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="270" y="335.02" width="100" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 350px; margin-left: 271px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="320" y="354" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">resuming</text></switch></g><path d="M 500 149.96 L 500 223.59" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 500 228.84 L 496.5 221.84 L 500 223.59 L 503.5 221.84 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="460" y="119.96" width="80" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 135px; margin-left: 461px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">closed</div></div></div></foreignObject><text x="500" y="139" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">closed</text></switch></g><path d="M 520 259.96 L 520 280 L 630 280 L 630 266.33" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 630 261.08 L 633.5 268.08 L 630 266.33 L 626.5 268.08 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 480 229.96 L 480 190 L 480 156.33" fill="noneLine truncated
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -1,4 +0,0 @@
|
||||
for x in $(dirname $0)/*.dot; do
|
||||
echo $x
|
||||
dot $x -Tsvg -o ${x/.dot/.svg}
|
||||
done
|
||||
@@ -1,23 +0,0 @@
|
||||
digraph "Client Reconnection Flow" {
|
||||
node [shape=box];
|
||||
graph [newrank=true];
|
||||
|
||||
reconnect [label="Reconnect"];
|
||||
wait_hello [label="Wait for hello"];
|
||||
resume_session [label="Resume session"];
|
||||
new_session [label="New session" style="dashed"];
|
||||
wait_continue [label="Wait for continue"];
|
||||
connected [label="Connected"];
|
||||
lost_connection [label="Connection lost or\nunexpectedly closed\nwhile reconnecting" style="dotted"];
|
||||
abort [label="Abort"];
|
||||
|
||||
{ rank="same"; wait_continue; new_session };
|
||||
|
||||
lost_connection -> reconnect [label="Wait random delay"];
|
||||
reconnect -> wait_hello [label="Connect to /api/socket"];
|
||||
wait_hello -> resume_session [label="Receive hello"];
|
||||
resume_session -> wait_continue [label="Send resume"];
|
||||
wait_continue -> abort [label="Connection closed with 4003"];
|
||||
wait_continue -> new_session [label="Receive invalidate"];
|
||||
wait_continue -> connected [label="Receive ready"];
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.3 (0)
|
||||
-->
|
||||
<!-- Title: Client Reconnection Flow Pages: 1 -->
|
||||
<svg width="441pt" height="496pt"
|
||||
viewBox="0.00 0.00 441.00 496.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 492)">
|
||||
<title>Client Reconnection Flow</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-492 437,-492 437,4 -4,4"/>
|
||||
<!-- reconnect -->
|
||||
<g id="node1" class="node">
|
||||
<title>reconnect</title>
|
||||
<polygon fill="none" stroke="black" points="153.5,-384 62.5,-384 62.5,-348 153.5,-348 153.5,-384"/>
|
||||
<text text-anchor="middle" x="108" y="-362.3" font-family="Times,serif" font-size="14.00">Reconnect</text>
|
||||
</g>
|
||||
<!-- wait_hello -->
|
||||
<g id="node2" class="node">
|
||||
<title>wait_hello</title>
|
||||
<polygon fill="none" stroke="black" points="164.5,-297 51.5,-297 51.5,-261 164.5,-261 164.5,-297"/>
|
||||
<text text-anchor="middle" x="108" y="-275.3" font-family="Times,serif" font-size="14.00">Wait for hello</text>
|
||||
</g>
|
||||
<!-- reconnect->wait_hello -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>reconnect->wait_hello</title>
|
||||
<path fill="none" stroke="black" d="M108,-347.8C108,-336.16 108,-320.55 108,-307.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-307.18 108,-297.18 104.5,-307.18 111.5,-307.18"/>
|
||||
<text text-anchor="middle" x="187.5" y="-318.8" font-family="Times,serif" font-size="14.00">Connect to /api/socket</text>
|
||||
</g>
|
||||
<!-- resume_session -->
|
||||
<g id="node3" class="node">
|
||||
<title>resume_session</title>
|
||||
<polygon fill="none" stroke="black" points="173.5,-210 42.5,-210 42.5,-174 173.5,-174 173.5,-210"/>
|
||||
<text text-anchor="middle" x="108" y="-188.3" font-family="Times,serif" font-size="14.00">Resume session</text>
|
||||
</g>
|
||||
<!-- wait_hello->resume_session -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>wait_hello->resume_session</title>
|
||||
<path fill="none" stroke="black" d="M108,-260.8C108,-249.16 108,-233.55 108,-220.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-220.18 108,-210.18 104.5,-220.18 111.5,-220.18"/>
|
||||
<text text-anchor="middle" x="155.5" y="-231.8" font-family="Times,serif" font-size="14.00">Receive hello</text>
|
||||
</g>
|
||||
<!-- wait_continue -->
|
||||
<g id="node5" class="node">
|
||||
<title>wait_continue</title>
|
||||
<polygon fill="none" stroke="black" points="178,-123 38,-123 38,-87 178,-87 178,-123"/>
|
||||
<text text-anchor="middle" x="108" y="-101.3" font-family="Times,serif" font-size="14.00">Wait for continue</text>
|
||||
</g>
|
||||
<!-- resume_session->wait_continue -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>resume_session->wait_continue</title>
|
||||
<path fill="none" stroke="black" d="M108,-173.8C108,-162.16 108,-146.55 108,-133.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-133.18 108,-123.18 104.5,-133.18 111.5,-133.18"/>
|
||||
<text text-anchor="middle" x="155" y="-144.8" font-family="Times,serif" font-size="14.00">Send resume</text>
|
||||
</g>
|
||||
<!-- new_session -->
|
||||
<g id="node4" class="node">
|
||||
<title>new_session</title>
|
||||
<polygon fill="none" stroke="black" stroke-dasharray="5,2" points="433,-123 327,-123 327,-87 433,-87 433,-123"/>
|
||||
<text text-anchor="middle" x="380" y="-101.3" font-family="Times,serif" font-size="14.00">New session</text>
|
||||
</g>
|
||||
<!-- wait_continue->new_session -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>wait_continue->new_session</title>
|
||||
<path fill="none" stroke="black" d="M178.05,-105C220.94,-105 275.39,-105 316.94,-105"/>
|
||||
<polygon fill="black" stroke="black" points="316.94,-108.5 326.94,-105 316.94,-101.5 316.94,-108.5"/>
|
||||
<text text-anchor="middle" x="252.5" y="-111.8" font-family="Times,serif" font-size="14.00">Receive invalidate</text>
|
||||
</g>
|
||||
<!-- connected -->
|
||||
<g id="node6" class="node">
|
||||
<title>connected</title>
|
||||
<polygon fill="none" stroke="black" points="92,-36 0,-36 0,0 92,0 92,-36"/>
|
||||
<text text-anchor="middle" x="46" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
|
||||
</g>
|
||||
<!-- wait_continue->connected -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>wait_continue->connected</title>
|
||||
<path fill="none" stroke="black" d="M61.23,-86.99C53.95,-82.28 47.4,-76.37 43,-69 39.01,-62.33 38.18,-54.22 38.78,-46.49"/>
|
||||
<polygon fill="black" stroke="black" points="42.27,-46.77 40.32,-36.36 35.35,-45.72 42.27,-46.77"/>
|
||||
<text text-anchor="middle" x="93.5" y="-57.8" font-family="Times,serif" font-size="14.00">Receive ready</text>
|
||||
</g>
|
||||
<!-- abort -->
|
||||
<g id="node8" class="node">
|
||||
<title>abort</title>
|
||||
<polygon fill="none" stroke="black" points="198,-36 142,-36 142,0 198,0 198,-36"/>
|
||||
<text text-anchor="middle" x="170" y="-14.3" font-family="Times,serif" font-size="14.00">Abort</text>
|
||||
</g>
|
||||
<!-- wait_continue->abort -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>wait_continue->abort</title>
|
||||
<path fill="none" stroke="black" d="M131.28,-86.66C137.25,-81.45 143.3,-75.4 148,-69 153.19,-61.93 157.5,-53.45 160.88,-45.5"/>
|
||||
<polygon fill="black" stroke="black" points="164.14,-46.78 164.56,-36.19 157.63,-44.21 164.14,-46.78"/>
|
||||
<text text-anchor="middle" x="260.5" y="-57.8" font-family="Times,serif" font-size="14.00">Connection closed with 4003</text>
|
||||
</g>
|
||||
<!-- lost_connection -->
|
||||
<g id="node7" class="node">
|
||||
<title>lost_connection</title>
|
||||
<polygon fill="none" stroke="black" stroke-dasharray="1,5" points="188.5,-488 27.5,-488 27.5,-435 188.5,-435 188.5,-488"/>
|
||||
<text text-anchor="middle" x="108" y="-472.8" font-family="Times,serif" font-size="14.00">Connection lost or</text>
|
||||
<text text-anchor="middle" x="108" y="-457.8" font-family="Times,serif" font-size="14.00">unexpectedly closed</text>
|
||||
<text text-anchor="middle" x="108" y="-442.8" font-family="Times,serif" font-size="14.00">while reconnecting</text>
|
||||
</g>
|
||||
<!-- lost_connection->reconnect -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>lost_connection->reconnect</title>
|
||||
<path fill="none" stroke="black" d="M108,-434.95C108,-422.36 108,-407.17 108,-394.35"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-394.24 108,-384.24 104.5,-394.24 111.5,-394.24"/>
|
||||
<text text-anchor="middle" x="175.5" y="-405.8" font-family="Times,serif" font-size="14.00">Wait random delay</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -1,21 +0,0 @@
|
||||
digraph "Server Connection Flow" {
|
||||
node [shape=box];
|
||||
graph [newrank=true];
|
||||
|
||||
new_connection [label="New connection"];
|
||||
wait_handshake [label="Wait for handshake"];
|
||||
validate_register [label="Validate register"];
|
||||
validate_resume [label="Validate resume"];
|
||||
connected [label="Connected"];
|
||||
close [label="Close connection"];
|
||||
|
||||
new_connection -> wait_handshake [label="Send hello"];
|
||||
wait_handshake -> validate_register [label="Receive register_*"];
|
||||
wait_handshake -> validate_resume [label="Receive resume"];
|
||||
wait_handshake -> close [label="Bad handshake\nTimeout"];
|
||||
validate_register -> close [label="Bad token\nBad register"];
|
||||
validate_register -> connected [label="Valid register:\nSend ready"];
|
||||
validate_resume -> close [label="Bad token\nBad resume"];
|
||||
validate_resume -> wait_handshake [label="Bad session:\nsend invalidate"];
|
||||
validate_resume -> connected [label="Valid resume:\nSend continue"];
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.3 (0)
|
||||
-->
|
||||
<!-- Title: Server Connection Flow Pages: 1 -->
|
||||
<svg width="603pt" height="335pt"
|
||||
viewBox="0.00 0.00 603.00 335.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 331)">
|
||||
<title>Server Connection Flow</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-331 599,-331 599,4 -4,4"/>
|
||||
<!-- new_connection -->
|
||||
<g id="node1" class="node">
|
||||
<title>new_connection</title>
|
||||
<polygon fill="none" stroke="black" points="355.5,-327 224.5,-327 224.5,-291 355.5,-291 355.5,-327"/>
|
||||
<text text-anchor="middle" x="290" y="-305.3" font-family="Times,serif" font-size="14.00">New connection</text>
|
||||
</g>
|
||||
<!-- wait_handshake -->
|
||||
<g id="node2" class="node">
|
||||
<title>wait_handshake</title>
|
||||
<polygon fill="none" stroke="black" points="367.5,-240 212.5,-240 212.5,-204 367.5,-204 367.5,-240"/>
|
||||
<text text-anchor="middle" x="290" y="-218.3" font-family="Times,serif" font-size="14.00">Wait for handshake</text>
|
||||
</g>
|
||||
<!-- new_connection->wait_handshake -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>new_connection->wait_handshake</title>
|
||||
<path fill="none" stroke="black" d="M290,-290.8C290,-279.16 290,-263.55 290,-250.24"/>
|
||||
<polygon fill="black" stroke="black" points="293.5,-250.18 290,-240.18 286.5,-250.18 293.5,-250.18"/>
|
||||
<text text-anchor="middle" x="328" y="-261.8" font-family="Times,serif" font-size="14.00">Send hello</text>
|
||||
</g>
|
||||
<!-- validate_register -->
|
||||
<g id="node3" class="node">
|
||||
<title>validate_register</title>
|
||||
<polygon fill="none" stroke="black" points="136,-138 0,-138 0,-102 136,-102 136,-138"/>
|
||||
<text text-anchor="middle" x="68" y="-116.3" font-family="Times,serif" font-size="14.00">Validate register</text>
|
||||
</g>
|
||||
<!-- wait_handshake->validate_register -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>wait_handshake->validate_register</title>
|
||||
<path fill="none" stroke="black" d="M212.45,-216.73C161.73,-212.32 101.57,-203.5 83,-186 72.82,-176.41 68.91,-161.41 67.62,-148.23"/>
|
||||
<polygon fill="black" stroke="black" points="71.11,-147.99 67.07,-138.2 64.12,-148.37 71.11,-147.99"/>
|
||||
<text text-anchor="middle" x="148.5" y="-167.3" font-family="Times,serif" font-size="14.00">Receive register_*</text>
|
||||
</g>
|
||||
<!-- validate_resume -->
|
||||
<g id="node4" class="node">
|
||||
<title>validate_resume</title>
|
||||
<polygon fill="none" stroke="black" points="356,-138 224,-138 224,-102 356,-102 356,-138"/>
|
||||
<text text-anchor="middle" x="290" y="-116.3" font-family="Times,serif" font-size="14.00">Validate resume</text>
|
||||
</g>
|
||||
<!-- wait_handshake->validate_resume -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>wait_handshake->validate_resume</title>
|
||||
<path fill="none" stroke="black" d="M241.15,-203.81C233.91,-199.14 227.42,-193.3 223,-186 216.09,-174.6 216.09,-167.4 223,-156 225.69,-151.55 229.16,-147.65 233.09,-144.22"/>
|
||||
<polygon fill="black" stroke="black" points="235.23,-146.98 241.15,-138.19 231.04,-141.38 235.23,-146.98"/>
|
||||
<text text-anchor="middle" x="280" y="-167.3" font-family="Times,serif" font-size="14.00">Receive resume</text>
|
||||
</g>
|
||||
<!-- close -->
|
||||
<g id="node6" class="node">
|
||||
<title>close</title>
|
||||
<polygon fill="none" stroke="black" points="450.5,-36 313.5,-36 313.5,0 450.5,0 450.5,-36"/>
|
||||
<text text-anchor="middle" x="382" y="-14.3" font-family="Times,serif" font-size="14.00">Close connection</text>
|
||||
</g>
|
||||
<!-- wait_handshake->close -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>wait_handshake->close</title>
|
||||
<path fill="none" stroke="black" d="M367.54,-213.17C403.99,-207.82 442.06,-199.24 454,-186 493.43,-142.29 497.55,-103.05 465,-54 461.92,-49.36 458.09,-45.33 453.78,-41.84"/>
|
||||
<polygon fill="black" stroke="black" points="455.76,-38.95 445.55,-36.1 451.76,-44.69 455.76,-38.95"/>
|
||||
<text text-anchor="middle" x="540.5" y="-123.8" font-family="Times,serif" font-size="14.00">Bad handshake</text>
|
||||
<text text-anchor="middle" x="540.5" y="-108.8" font-family="Times,serif" font-size="14.00">Timeout</text>
|
||||
</g>
|
||||
<!-- connected -->
|
||||
<g id="node5" class="node">
|
||||
<title>connected</title>
|
||||
<polygon fill="none" stroke="black" points="127,-36 35,-36 35,0 127,0 127,-36"/>
|
||||
<text text-anchor="middle" x="81" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
|
||||
</g>
|
||||
<!-- validate_register->connected -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>validate_register->connected</title>
|
||||
<path fill="none" stroke="black" d="M34.78,-101.9C28.43,-97.02 22.64,-91.06 19,-84 12.9,-72.14 12.28,-65.51 19,-54 21.52,-49.69 24.78,-45.87 28.5,-42.49"/>
|
||||
<polygon fill="black" stroke="black" points="30.9,-45.05 36.66,-36.16 26.61,-39.52 30.9,-45.05"/>
|
||||
<text text-anchor="middle" x="70" y="-72.8" font-family="Times,serif" font-size="14.00">Valid register:</text>
|
||||
<text text-anchor="middle" x="70" y="-57.8" font-family="Times,serif" font-size="14.00">Send ready</text>
|
||||
</g>
|
||||
<!-- validate_register->close -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>validate_register->close</title>
|
||||
<path fill="none" stroke="black" d="M136,-111.34C178.72,-105.47 229.03,-96.29 247,-84 260.03,-75.09 253.43,-63.55 266,-54 277.12,-45.55 290.27,-39.13 303.66,-34.26"/>
|
||||
<polygon fill="black" stroke="black" points="305.06,-37.48 313.42,-30.97 302.83,-30.84 305.06,-37.48"/>
|
||||
<text text-anchor="middle" x="311" y="-72.8" font-family="Times,serif" font-size="14.00">Bad token</text>
|
||||
<text text-anchor="middle" x="311" y="-57.8" font-family="Times,serif" font-size="14.00">Bad register</text>
|
||||
</g>
|
||||
<!-- validate_resume->wait_handshake -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>validate_resume->wait_handshake</title>
|
||||
<path fill="none" stroke="black" d="M321.89,-138.33C327.98,-143.2 333.53,-149.09 337,-156 342.98,-167.92 342.98,-174.08 337,-186 335.05,-189.89 332.44,-193.45 329.44,-196.69"/>
|
||||
<polygon fill="black" stroke="black" points="326.86,-194.32 321.89,-203.67 331.61,-199.46 326.86,-194.32"/>
|
||||
<text text-anchor="middle" x="395.5" y="-174.8" font-family="Times,serif" font-size="14.00">Bad session:</text>
|
||||
<text text-anchor="middle" x="395.5" y="-159.8" font-family="Times,serif" font-size="14.00">send invalidate</text>
|
||||
</g>
|
||||
<!-- validate_resume->connected -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>validate_resume->connected</title>
|
||||
<path fill="none" stroke="black" d="M223.7,-112.81C197.28,-107.96 167.61,-99.33 144,-84 129.91,-74.85 132.49,-66.26 121,-54 117.46,-50.23 113.56,-46.43 109.61,-42.79"/>
|
||||
<polygon fill="black" stroke="black" points="111.86,-40.11 102.07,-36.06 107.2,-45.33 111.86,-40.11"/>
|
||||
<text text-anchor="middle" x="195.5" y="-72.8" font-family="Times,serif" font-size="14.00">Valid resume:</text>
|
||||
<text text-anchor="middle" x="195.5" y="-57.8" font-family="Times,serif" font-size="14.00">Send continue</text>
|
||||
</g>
|
||||
<!-- validate_resume->close -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>validate_resume->close</title>
|
||||
<path fill="none" stroke="black" d="M336.9,-101.98C345.46,-97.2 353.66,-91.27 360,-84 369.17,-73.47 374.56,-58.96 377.7,-46.32"/>
|
||||
<polygon fill="black" stroke="black" points="381.18,-46.78 379.85,-36.27 374.33,-45.32 381.18,-46.78"/>
|
||||
<text text-anchor="middle" x="418" y="-72.8" font-family="Times,serif" font-size="14.00">Bad token</text>
|
||||
<text text-anchor="middle" x="418" y="-57.8" font-family="Times,serif" font-size="14.00">Bad resume</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,59 +1,36 @@
|
||||
Communication Layer
|
||||
===================
|
||||
# Communication Layer
|
||||
|
||||
<sub>This document describes the communication layer in Clusterio and
|
||||
unless you're developing Clusterio it's probably not going to be very
|
||||
useful for you.</sub>
|
||||
<sub>This document describes the communication layer in Clusterio and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||
|
||||
Communication is facilitated by the lib/link library and is based on
|
||||
three core abstractions: links, connectors and messages. Links
|
||||
represents one side of a two way communication pipe, connectors deal
|
||||
with connecting and establishing communication, and messages are the
|
||||
content sent over the link.
|
||||
Communication is facilitated by the lib/link library and is based on three core abstractions: links, connectors and messages.
|
||||
Links represents one side of a two way communication pipe, connectors deal with connecting and establishing communication, and messages are the content sent over the link.
|
||||
|
||||
|
||||
Link
|
||||
----
|
||||
## Link
|
||||
|
||||
The Link class represents one side of a two way communication channel
|
||||
where the actual communication is done by a connector associated with
|
||||
the link. The link also knows what the endpoint types it's connected to
|
||||
are via the source and target attributes.
|
||||
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
|
||||
The link also knows what the endpoint types it's connected to are via the source and target attributes.
|
||||
|
||||
The endpoint types currently implemented are master, slave, instance,
|
||||
and control. Although slave and instances run in the same Node.js
|
||||
program they use virtual links to communicate between them in order to
|
||||
simplify the communication architecture.
|
||||
The endpoint types currently implemented are controller, host, instance, and control.
|
||||
Although host and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
|
||||
|
||||
Upon creation the link will register with the connector in order to
|
||||
receive and process messages from it. The messages received are
|
||||
validated using the validator registered for the type of the message. A
|
||||
message for which there is no validator for is considered an error.
|
||||
Upon creation the link will register with the connector in order to receive and process messages from it.
|
||||
The messages received are validated using the validator registered for the type of the message.
|
||||
A message for which there is no validator for is considered an error.
|
||||
|
||||
|
||||
Connector
|
||||
---------
|
||||
## Connector
|
||||
|
||||
The connector used with a link is responsible for establishing and/or
|
||||
maintain the connection that messages are sent over. Reconnect logic is
|
||||
expected to be implemented in the connector, though this has not yet
|
||||
been done.
|
||||
The connector used with a link is responsible for establishing and/or maintain the connection that messages are sent over.
|
||||
Reconnect logic is expected to be implemented in the connector, though this has not yet been done.
|
||||
|
||||
There's also the virtual connector which lets two links in the same
|
||||
program be connected to each other without having to establish a
|
||||
network connection.
|
||||
There's also the virtual connector which lets two links in the same program be connected to each other without having to establish a network connection.
|
||||
|
||||
|
||||
Message
|
||||
-------
|
||||
## Message
|
||||
|
||||
There's two types of messages currently implemented apart from the
|
||||
Link builtin close message and that's Request and Event. The builtin
|
||||
messages are defined in
|
||||
[packages/lib/link/messages.js](/packages/lib/link/messages.js) and are
|
||||
attached to links via the attachAllMessages function, which is usally
|
||||
called in the Link subclass constructor.
|
||||
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
|
||||
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
|
||||
|
||||
The messages define which links they are valid on and only attaches
|
||||
handlers for those links. There's also a forwarding mechanism for when
|
||||
a request or event is to be forwarded another hop.
|
||||
The messages define which links they are valid on and only attaches handlers for those links.
|
||||
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
|
||||
@@ -1,102 +1,76 @@
|
||||
Configuration System
|
||||
====================
|
||||
# Configuration System
|
||||
|
||||
<sub>This document describes the implementation and inner working of the
|
||||
configuration system in Clusterio, and unless you're developing
|
||||
Clusterio it's probably not going to be very useful for you.</sub>
|
||||
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||
|
||||
Clusterio uses a group based configuration system to manage settings for
|
||||
the master server, instances, slaves, and the clusterioctl utility.
|
||||
Plugins get their own config group for the master server and instances
|
||||
which they can add their own fields to. The fields are defined early in
|
||||
the startup, and the groups and their fields for disabled plugins will
|
||||
still be created.
|
||||
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
|
||||
Plugins can add their own config fields for the controller and instances configs.
|
||||
The fields are defined early in the startup, and fields for disabled plugins will still be created.
|
||||
|
||||
The built-in config groups and their fields are defined in
|
||||
[packages/lib/config/definitions.js](/packages/lib/config/definitions.js)
|
||||
while plugins can add their own config groups to the `MasterConfigGroup`
|
||||
and `InstanceConfigGroup` properties of the `info.js` export using
|
||||
subclasses of `PluginConfigGroup`. The plugin group has the enabled
|
||||
field pre-defined on it, and it's used to enable/disable plugins.
|
||||
The built-in config fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
|
||||
|
||||
|
||||
Defing Fields
|
||||
-------------
|
||||
## Defing Fields
|
||||
|
||||
Fields are defined using the `define` class method on group classes
|
||||
after the groupName have been set and before the class has been
|
||||
finalized. The `define` method takes an object as argument with the
|
||||
following properties:
|
||||
Fields are defined as POJS object properties where the name of the property correspond to the name of the configuration field.
|
||||
The property name is the primary way the field is accessed both in code and on the command line, as such it should follow the lower\_case\_underscore style and consist of a group name and a field name separated by a dot.
|
||||
For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.lavel`.
|
||||
|
||||
**access**:
|
||||
The locations this config field can be read and modified from.
|
||||
This is an array of strings which can contain the values `"controller"`, `"host"` and `"control"`.
|
||||
If a party is missing in this array then that party will not receive the value of this field when the config is shared, and will be unable to modify the field.
|
||||
If not set any party can read and modify this config entry.
|
||||
Note that instance config fields should have both `"controller"` and `"host"` or unexpected behaviour may occur.
|
||||
|
||||
**type**:
|
||||
The type of config value this field will support. The supported
|
||||
values are boolean, string, number, and object, and if an attept is
|
||||
made to set the field to a value that is not of the right type it
|
||||
will be reject unless it's a string that can be converted to the
|
||||
right type.
|
||||
|
||||
**name**:
|
||||
Name of the configuration field, this is the primary way the field
|
||||
is accessed, both in code and on the command line. As such it
|
||||
should follow the lower\_case\_underscore style and not contain any
|
||||
spaces or dots.
|
||||
The type of config value this field will support.
|
||||
The supported values are boolean, string, number, and object, and if an attept is made to set the field to a value that is not of the right type it will be reject unless it's a string that can be converted to the right type.
|
||||
|
||||
**title** (optional):
|
||||
Text used to identify the config field in user interfaces.
|
||||
This should not contain the grouping used in `name`.
|
||||
|
||||
**description** (optional):
|
||||
Text used to describe the config field in user interfaces.
|
||||
|
||||
**enum** (optional):
|
||||
Array of acceptable values this field can have. Attempts to set a
|
||||
value not in this array will be rejected, which makes it useful for
|
||||
making enumareted list of choices.
|
||||
Array of acceptable values this field can have.
|
||||
Attempts to set a value not in this array will be rejected, which makes it useful for making enumareted list of choices.
|
||||
|
||||
**optional** (optional):
|
||||
True if this field can be set to null. Defaults to false.
|
||||
True if this field can be set to null.
|
||||
Defaults to false.
|
||||
|
||||
**initial_value** (optional if optional is true):
|
||||
**initialValue** (optional if optional is true):
|
||||
Value this config field should take in a newly initialized config.
|
||||
This can also be an async function returning the value to use.
|
||||
|
||||
If the value for a field stored in on disk somehow ends up being invalid
|
||||
the field will take on the initial\_value instead when the config is
|
||||
loaded.
|
||||
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
|
||||
|
||||
|
||||
Accessing Fields
|
||||
----------------
|
||||
## Accessing Fields
|
||||
|
||||
On the Config instance there's two ways to access fields: through the
|
||||
set and get methods using dot notation. E.g.:
|
||||
On the Config instance there's two ways to access fields: through the set and get methods.
|
||||
E.g.:
|
||||
|
||||
let value = configInstance.get("group.field");
|
||||
|
||||
Or by getting the group first and then calling set or get on that.
|
||||
Or by through the raw fields object (which is best avoided as it doesn't check types or whether the accessed field exists).
|
||||
E.g.:
|
||||
|
||||
let value = configInstance.group("group").get("field")
|
||||
let value = configInstance.fields["group.field"]
|
||||
|
||||
The set method takes an extra argument, which is the value to set the
|
||||
field to. If the value is not valid this will throw an InvalidValue
|
||||
exception, if the group or field doesn't exist it will throw an
|
||||
InvalidField exception.
|
||||
The set method also takes an extra argument, which is the value to set the field to.
|
||||
If the value is not valid this will throw an InvalidValue exception, if the field doesn't exist it will throw an InvalidField exception.
|
||||
|
||||
|
||||
Config Lifecycle
|
||||
----------------
|
||||
## Config Lifecycle
|
||||
|
||||
Values for all config fields are created when the config is initialized,
|
||||
as well as for any fields with missing values when a config is loaded.
|
||||
Once a field has a value it is never automatically removed, even if the
|
||||
field or group it was part of no longer exists. This ensures that
|
||||
plugin configuration is not lost should the server be started up without
|
||||
the plugins that defined the configuration installed.
|
||||
Values for all config fields are created when the config is initialized, as well as for any fields with missing values when a config is loaded.
|
||||
Once a field has a value it is never automatically removed, even if the field it was part of no longer exists.
|
||||
This ensures that plugin configuration is not lost should the server be started up without the plugins that defined the configuration installed.
|
||||
|
||||
For instance configs, they are kept on the master server and serialized
|
||||
and sent over to the relevant slaves on startup. This makes it possible
|
||||
to edit instance configs when the slave the instance is on is offline.
|
||||
A copy is also stored in the instance directory on the slave, and this
|
||||
config will be deliverd to the master server on slave startup, which
|
||||
enables instances to be copied between clusters as long as their IDs are
|
||||
unique.
|
||||
For instance configs, they are kept on the controller and serialized and sent over to the relevant hosts on startup.
|
||||
This makes it possible to edit instance configs when the host the instance is on is offline.
|
||||
A copy is also stored in the instance directory on the host, and this config will be deliverd to the controller on host startup, which enables instances to be copied between clusters as long as their IDs are unique.
|
||||
@@ -0,0 +1,421 @@
|
||||
# Configuration
|
||||
|
||||
|
||||
## Controller Configuration
|
||||
|
||||
### controller.name
|
||||
|
||||
Name of the cluster.
|
||||
Used to distinguish it from other clusters and is prepended to the name of servers in the server list.
|
||||
|
||||
Defaluts to "Your Cluster".
|
||||
|
||||
|
||||
### controller.database_directory
|
||||
|
||||
Directory used to store cluster data to.
|
||||
Needs to be writeable by the controller.
|
||||
This is also used by plugins to store its data.
|
||||
|
||||
Defaults to "database".
|
||||
|
||||
|
||||
### controller.http_port
|
||||
|
||||
Port to host HTTP server on.
|
||||
If set to null no HTTP server will be exposed.
|
||||
At least one of this and controller.https_port needs to be set to a port.
|
||||
|
||||
Defaults to 8080.
|
||||
|
||||
|
||||
### controller.https_port
|
||||
|
||||
Port to host HTTPS server on.
|
||||
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
|
||||
If set to null no HTTPS server will be exposed.
|
||||
At least one of this and controller.http_port needs to be set to a port.
|
||||
|
||||
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.bind_address
|
||||
|
||||
IP address to bind the HTTP and HTTPS ports on.
|
||||
Useful to limit which interface to accept connections from.
|
||||
If set to null the unspecified IPv6 address will be used, which on most systems means it will also listen on the unspecified IPv4 address and accept connections from all
|
||||
interfaces.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.external_address
|
||||
|
||||
External address the controller is accessible on.
|
||||
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
|
||||
This should be a full URL ending with a /.
|
||||
|
||||
Defaults to null meaning assume localhost.
|
||||
|
||||
|
||||
### controller.tls_certificate
|
||||
|
||||
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
|
||||
|
||||
Defaults to "database/certificates/cert.crt".
|
||||
|
||||
|
||||
### controller.tls_private_key
|
||||
|
||||
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
|
||||
|
||||
Defaults to "database/certificates/cert.key".
|
||||
|
||||
|
||||
### controller.auth_secret
|
||||
|
||||
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
|
||||
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
|
||||
Keep this secret if leaked an attacker could easily compromise the cluster.
|
||||
|
||||
Defaults to null which means generate a secure secret on startup.
|
||||
|
||||
|
||||
### controller.heartbeat_interval
|
||||
|
||||
Interval in seconds heartbeats are sent out at for WebSocket connections.
|
||||
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
A lower value means less time between connections going stale and them being closed.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.session_timeout
|
||||
|
||||
Time in seconds from the connection is closed following a heartbeat timeout until the session is invalidated and data loss occurs.
|
||||
A higher timeout gives more time for a client to recconect and resume an active session over a connection that was dropped, but also leads to stale connections taking longer to clear out.
|
||||
In the case of the controller or client crashing the data loss is unavoidable.
|
||||
|
||||
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.metrics_timeout
|
||||
|
||||
Timeout in seconds before a call to gather metrics from a host times out.
|
||||
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
|
||||
|
||||
Defaults to 8.
|
||||
|
||||
|
||||
### controller.stream_timeout
|
||||
|
||||
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
|
||||
Streams that have started sending data before this timeout is not affected.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.default_role_id
|
||||
|
||||
Role to automatically grant to new users.
|
||||
If null no role is granted.
|
||||
|
||||
Defaults to 1 which correspond to the default Player role.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the controller.
|
||||
Plugins not loaded on the controller will not be loaded on instances.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Host Configuration
|
||||
|
||||
### host.name
|
||||
|
||||
Name of the host.
|
||||
Shows up in host lists and is used to reference this host in the clusterioctl command line interface.
|
||||
|
||||
Defaults to "New Host".
|
||||
|
||||
|
||||
### host.id
|
||||
|
||||
Immutable numeric id of the host which uniquely identifies this host in the cluster.
|
||||
|
||||
Defaults to a random 31 bit number.
|
||||
|
||||
|
||||
### host.factorio_directory
|
||||
|
||||
Directory to look for the Factorio server(s) in.
|
||||
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
|
||||
|
||||
Defaults to "factorio".
|
||||
|
||||
|
||||
### host.instances_directory
|
||||
|
||||
Directory to store instances in.
|
||||
One sub-directory will be created in it for each instance assigned to this host and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
|
||||
|
||||
Defaults to "instances".
|
||||
|
||||
|
||||
### host.controller_url
|
||||
|
||||
URL to connect to the controller to.
|
||||
|
||||
Defaults to "http://localhost:8080/".
|
||||
|
||||
|
||||
### host.controller_token
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
|
||||
|
||||
Defaults to "enter token here".
|
||||
|
||||
|
||||
### host.tls_ca
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the controller.
|
||||
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### host.public_address
|
||||
|
||||
External address instances hosted on this host can be accessed on.
|
||||
This is used by plugins like Server Select to give the correct address to switch between instances in-game.
|
||||
|
||||
Defaults to "localhost".
|
||||
|
||||
|
||||
### host.max_reconnect_delay
|
||||
|
||||
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
## Instance Configuration
|
||||
|
||||
### instance.name
|
||||
|
||||
Name of the instance, shown in instance lists and used to reference to this instance in the clusterioctl command line interface.
|
||||
|
||||
Defaults to "New Instance".
|
||||
|
||||
|
||||
### instance.id
|
||||
|
||||
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
|
||||
|
||||
Defaluts to a random 31 bit integer.
|
||||
|
||||
|
||||
### instance.assigned_host
|
||||
|
||||
Host this instance is assigned to.
|
||||
To change this you need to use the separate assign instance interface.
|
||||
After being assigned to a host the directory for the instance is created on the host.
|
||||
|
||||
Defaults to null meaning not assigned.
|
||||
|
||||
|
||||
### instance.auto_start
|
||||
|
||||
If enabled start this instance when the host it is assigned to is started up.
|
||||
Does not affect start or stop of the instance while the host is running.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
|
||||
|
||||
Defaults to "latest".
|
||||
|
||||
|
||||
### factorio.game_port
|
||||
|
||||
UDP port to run the Factorio server on.
|
||||
If null a random port in the dynamyc range assigned each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.rcon_port
|
||||
|
||||
TCP port to start the RCON interface on the Factorio server on.
|
||||
If null a random port in the dynamic range is assigned each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.rcon_password
|
||||
|
||||
Password to use for the RCON interafec.
|
||||
If null a random secure password is generated and used each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### player_online_autosave_slots
|
||||
|
||||
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
|
||||
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
|
||||
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
|
||||
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
|
||||
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
|
||||
|
||||
If 0 no autosave renaming takes place.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
|
||||
### factorio.enable_save_patching
|
||||
|
||||
Whether to use save patching or not.
|
||||
When enabled lua code is patched into the game save before starting it.
|
||||
Most plugins require the use of save patching to function.
|
||||
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.enable_whitelist
|
||||
|
||||
Turn on the whitelist on the server.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.settings
|
||||
|
||||
Object with the settings to put into `server-settings.json` for the Factorio server.
|
||||
The settings in `data/server-settings.example.json` of the Factorio installation is used as the base and properties specified here overrides the properties there.
|
||||
|
||||
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
Defaults to {"tags":["clusterio"],"auto_pause":false}.
|
||||
|
||||
|
||||
### factorio.verbose_logging
|
||||
|
||||
Pass the `--verbose` flag to Factorio when starting the server.
|
||||
This prints more messages to the log.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.strip_paths
|
||||
|
||||
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
|
||||
This improves the signal to noise ratio of the log.
|
||||
|
||||
Sample output when disabled:
|
||||
|
||||
0.487 Loading map /srv/clusterio/instances/test/saves/world.zip: 2615888 bytes.
|
||||
0.614 Checksum for script /srv/clusterio/instances/test/temp/currently-playing/control.lua: 2390553941
|
||||
1.277 Script @/srv/clusterio/instances/test/temp/currently-playing/modules/example/test.lua:7: Example log line.
|
||||
|
||||
Sample output when enabled:
|
||||
|
||||
0.487 Loading map saves/world.zip: 2615888 bytes.
|
||||
0.614 Checksum for script temp/currently-playing/control.lua: 2390553941
|
||||
1.277 Script @modules/example/test.lua:7: Example log line.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
### factorio.sync_adminlist
|
||||
|
||||
Synchronize in-game admin list with admin status of the users in cluster.
|
||||
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.sync_whitelist
|
||||
|
||||
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
|
||||
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.sync_banlist
|
||||
|
||||
Synchronize in-game banlist with the banned status of users in the cluster.
|
||||
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.max_concurrent_commands
|
||||
|
||||
Maximum number of commands being transmitted into the game in parallel.
|
||||
Since the rate at which long commands are streamed into the game is by default limeted to 100 bytes/tick and scales down to 25 bytes/tick when there's 20 players connected, long commands can hold up the command interface and cause large latencies in getting data pushed into the game.
|
||||
(The rates are controlled by the `segment_size` options in the server settings.)
|
||||
To counteract this Clusterio sends up to this configured value number of commands in parallel over the RCON interface.
|
||||
This causes the game updates sent to clients to have this many command streams in parallel contained in them, drastically increasing the maxiumum size they can become.
|
||||
Since commands are only processed so ofter the maximum rate of commands that can be sustained is roughly 30 times this configured value per second, though note that large commands will lower this value.
|
||||
|
||||
The game updates sent by the server is split into roughly 500 byte packets, and if any one of those parts are lost the entire update is resent.
|
||||
This means a high number of concurrent commands can amplify resends due to packet loss and degrade the player experience up to the point of becomming completely unplayable.
|
||||
|
||||
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
|
||||
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the given plugin on the instance.
|
||||
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Control Configuration
|
||||
|
||||
### control.controller_url
|
||||
|
||||
URL to connect to the controller to.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.controller_token
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.tls_ca
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the controller.
|
||||
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### control.max_reconnect_delay
|
||||
|
||||
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
|
||||
Defaults to 60.
|
||||
@@ -1,13 +1,11 @@
|
||||
Contributing to Clusterio
|
||||
=========================
|
||||
# Contributing to Clusterio
|
||||
|
||||
You are probably reading this because you want to contribute code to
|
||||
Clusterio. Great! We need people like you. Though we have some
|
||||
conventions and workflows that we kindly ask you to follow.
|
||||
You are probably reading this because you want to contribute code to Clusterio.
|
||||
Great! We need people like you.
|
||||
Though we have some conventions and workflows that we kindly ask you to follow.
|
||||
|
||||
|
||||
Contents
|
||||
--------
|
||||
## Contents
|
||||
|
||||
- [Workflow](#workflow)
|
||||
- [Starting a Feature Branch](#starting-a-feature-branch)
|
||||
@@ -21,235 +19,237 @@ Contents
|
||||
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
|
||||
- [Code Style](#code-style)
|
||||
- [Naming Style](#naming-style)
|
||||
- [Units](#units)
|
||||
- [Indenting](#indenting)
|
||||
- [Line Length](#line-length)
|
||||
- [Markdown](#markdown)
|
||||
- [Pushing releases to npm](#pushing-releases-to-npm)
|
||||
|
||||
|
||||
Workflow
|
||||
--------
|
||||
## Workflow
|
||||
|
||||
While we don't require you to follow this workflow, it makes things much
|
||||
simpler for both you and us if you choose to do so. Especially when it
|
||||
comes to making the commit history reflect what happened and not be a
|
||||
giant ball of mess. GitHub's git workflow is less than ideal for
|
||||
dealing with small feature-branches so you might see things being done
|
||||
here a little diffrent than the usual.
|
||||
While we don't require you to follow this workflow, it makes things much simpler for both you and us if you choose to do so.
|
||||
Especially when it comes to making the commit history reflect what happened and not be a giant ball of mess.
|
||||
GitHub's git workflow is less than ideal for dealing with small feature-branches so you might see things being done here a little diffrent than the usual.
|
||||
|
||||
It is recommended that you use the command line git version for doing
|
||||
the operations described here. GUI wrappers tend to have different
|
||||
ideas of what is what and you might find it difficult to make it do the
|
||||
things described here. For doing the actual commits though, your
|
||||
faviorit git GUI should work just fine.
|
||||
It is recommended that you use the command line git version for doing the operations described here.
|
||||
GUI wrappers tend to have different ideas of what is what and you might find it difficult to make it do the things described here.
|
||||
For doing the actual commits though, your faviorit git GUI should work just fine.
|
||||
|
||||
If you pressed the fork button on GitHub and used `git clone` _on your
|
||||
own fork_ you can skip to the next paragraph. Otherwise you will have
|
||||
to click the fork button and clone your fork. If you already cloned the
|
||||
main GitHub repository you can change your clone to point at your own
|
||||
fork using the `git remote` command, but it's probably easier to delete
|
||||
your local clone and clone it again.
|
||||
If you pressed the fork button on GitHub and used `git clone` _on your own fork_ you can skip to the next paragraph.
|
||||
Otherwise you will have to click the fork button and clone your fork.
|
||||
If you already cloned the main GitHub repository you can change your clone to point at your own fork using the `git remote` command, but it's probably easier to delete your local clone and clone it again.
|
||||
|
||||
To keep your local repository up to to date with the main repository add
|
||||
the main repository as a remote.
|
||||
To keep your local repository up to to date with the main repository add the main repository as a remote.
|
||||
|
||||
git remote add upstream https://github.com/clusterio/factorioClusterio
|
||||
git remote add upstream https://github.com/clusterio/clusterio
|
||||
|
||||
After this running `git fetch upstream` will get the updated branches
|
||||
from the main repository. Do not run `git pull upstream` it will mess
|
||||
thing up!
|
||||
After this running `git fetch upstream` will get the updated branches from the main repository.
|
||||
Do not run `git pull upstream` it will mess thing up!
|
||||
|
||||
|
||||
### Setting up the Development Environment
|
||||
|
||||
The project is organized into several packages that are all managed
|
||||
through Lerna. In order to get the development environment up and
|
||||
running you will need to run the following commands:
|
||||
The project is organized into several packages that are all tied together using pnpm's workspace feature.
|
||||
In order to get the development environment up and running you will need to run the following command:
|
||||
|
||||
npm install
|
||||
npx lerna bootstrap
|
||||
pnpm install
|
||||
|
||||
This installs dependencies needed by tests and links the packages up so
|
||||
they work from the git work tree.
|
||||
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
|
||||
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
|
||||
|
||||
node packages/create --dev
|
||||
|
||||
To add plugins to use in the development environment use
|
||||
|
||||
node packages/ctl plugin add ./path/to/plugin
|
||||
|
||||
It's important that the path starts with `./`.
|
||||
The `plugins/` directory contains the core plugins for the project and can be added using the above mentioned command.
|
||||
|
||||
The documentation uses the binaries provided by the packages, but for the development environment you will need to call the packages directly:
|
||||
|
||||
npx clusteriocontroller -> node packages/controller
|
||||
npx clusteriohost -> node packages/host
|
||||
npx clusterioctl -> node packages/ctl
|
||||
|
||||
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
|
||||
|
||||
Note that to use `clusterioctl` you will have to create a control config first:
|
||||
|
||||
node packages/controller bootstrap create-ctl-config [admin-user]
|
||||
|
||||
Once you've set up the cluster you can use `node packages/controller run` to run the controller and `node packages/host run` to run the host which connects to the controller.
|
||||
|
||||
When editing TypeScript files ran by Node.js you need to run `pnpm build` to compile the files to JavaScript before restarting the controller/host/ctl for changes take effect.
|
||||
To have TypeScript watch for changes and rebuild immediadly you may run `pnpm watch` instead.
|
||||
|
||||
For web development on the controller there are also the following flags:
|
||||
|
||||
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
|
||||
|
||||
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
|
||||
|
||||
These options can be combined and multiple plugins can be actived for dev mode.
|
||||
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
|
||||
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the controller without these flags again.
|
||||
|
||||
|
||||
### Starting a Feature Branch
|
||||
|
||||
Development is done exclusively in feature branches. This means the
|
||||
first thing to do before starting any development at all is to make a
|
||||
new branch starting from the master or 1.2.x branch of the main
|
||||
repository. To do this check out that branch from the main repository
|
||||
(substitute master for 1.2.x if you're working on that branch.)
|
||||
Development is done exclusively in feature branches.
|
||||
This means the first thing to do before starting any development at all is to make a new branch starting from the master branch of the main repository.
|
||||
To do this check out that branch from the main repository.
|
||||
|
||||
git fetch upstream
|
||||
git checkout upstream/master
|
||||
|
||||
This will give a warning about detached HEAD which means you did it
|
||||
correctly. Now you can make a feature branch based on this with.
|
||||
This will give a warning about detached HEAD which means you did it correctly.
|
||||
Now you can make a feature branch based on this with.
|
||||
|
||||
git checkout -b my-feature-branch
|
||||
|
||||
The name of the branch is not important, as long as it's not a name
|
||||
you've used before. But it's nice to have its name be somewhat
|
||||
descriptive of what it implements.
|
||||
The name of the branch is not important, as long as it's not a name you've used before.
|
||||
But it's nice to have its name be somewhat descriptive of what it implements.
|
||||
|
||||
|
||||
### Checking your code for errors
|
||||
|
||||
There's automated tests and linting setup for the project. Please do
|
||||
add tests for any code you add and run both:
|
||||
There's automated tests and linting setup for the project.
|
||||
Please do add tests for any code you add and run both:
|
||||
|
||||
npm run test
|
||||
npm run lint
|
||||
pnpm run test
|
||||
pnpm run lint
|
||||
|
||||
To check that your changes pass the integration tests as well as the
|
||||
ESLint rules set up for the project.
|
||||
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
|
||||
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
Once you've commited changes to your feature branch that you want to
|
||||
have included into the main repository push it to you fork with
|
||||
Once you've commited changes to your feature branch that you want to have included into the main repository push it to you fork with
|
||||
|
||||
git push origin my-feature-branch
|
||||
|
||||
and make a pull request through the GitHub web interface. You can also
|
||||
pass the `--set-upstream` (or `-u`) flag to make `git push` do the same
|
||||
thing for this branch. After your feature branch have been merged you
|
||||
should consider it final. If there are more changes you want to have
|
||||
merged or mistakes you need to fix you must start a new feature branch
|
||||
and submit a new pull request
|
||||
and make a pull request through the GitHub web interface.
|
||||
You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the same thing for this branch.
|
||||
After your feature branch have been merged you should consider it final.
|
||||
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
|
||||
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
Until your feature branch is merged you can edit it as you see fit by
|
||||
using the rebase tool. If your feature branch has been merged however
|
||||
it's final and you will have to start a new feature branch and submit
|
||||
that in a new pull request. The base usage of rebase assuming you are
|
||||
checked out on your feature branch is the following command (substitute
|
||||
master with 1.2.x if your feature branch is based on that)
|
||||
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
|
||||
If your feature branch has been merged however it's final and you will have to start a new feature branch and submit that in a new pull request.
|
||||
The base usage of rebase assuming you are checked out on your feature branch is the following command
|
||||
|
||||
git rebase -i upstream/master
|
||||
|
||||
This will open up an editor with the commits of your feature branch and
|
||||
let you reorder, edit, and remove commits as you see fit.
|
||||
This will open up an editor with the commits of your feature branch and let you reorder, edit, and remove commits as you see fit.
|
||||
|
||||
If the main repository has been updated and you have fetched down those
|
||||
updates with `git fetch upstream` this will also move your feature
|
||||
branch so that it is based on the latest commit from the main
|
||||
repository.
|
||||
If the main repository has been updated and you have fetched down those updates with `git fetch upstream` this will also move your feature branch so that it is based on the latest commit from the main repository.
|
||||
|
||||
If you have already pushed your feature branch to your fork a simple
|
||||
`git push origin my-feature-branch` will no longer work as the histories
|
||||
are different, instead you will have to run
|
||||
If you have already pushed your feature branch to your fork a simple `git push origin my-feature-branch` will no longer work as the histories are different, instead you will have to run
|
||||
|
||||
git push origin +my-feature-branch
|
||||
|
||||
The + sign signifies a force push and should be used with care. If you
|
||||
have a pull request that has not yet been merged submitted for this
|
||||
feature branch this will update the pull request too.
|
||||
The + sign signifies a force push and should be used with care.
|
||||
If you have a pull request that has not yet been merged submitted for this feature branch this will update the pull request too.
|
||||
|
||||
|
||||
### Resolving Merge Conflicts
|
||||
|
||||
The best way to resolve merge conflicts in feature braches is to rebase
|
||||
it on top of the updated upstream. Run the following commands
|
||||
(substitute master with 1.2.x if your feature branch is based on that)
|
||||
The best way to resolve merge conflicts in feature braches is to rebase it on top of the updated upstream.
|
||||
Run the following commands
|
||||
|
||||
git fetch upstream
|
||||
git rebase upstream/master
|
||||
|
||||
This will stop on the commit(s) that caused the merge conflict and let
|
||||
you resolve them manually before continuing. Once you're done force
|
||||
push your updated feature branch with
|
||||
This will stop on the commit(s) that caused the merge conflict and let you resolve them manually before continuing.
|
||||
Once you're done force push your updated feature branch with
|
||||
|
||||
git push origin +my-feature-branch
|
||||
|
||||
|
||||
### Updating Your GitHub Fork
|
||||
|
||||
No. This is pointless busywork that accomplishes nothing. Fetch the
|
||||
main repository directly as described above and base your feature
|
||||
branches on the refs from it. GitHub has no interface or functionality
|
||||
for keeping the branches in your fork up to date with the branches in
|
||||
the main repository and there is no point in doing this for our feature
|
||||
branch based workflow.
|
||||
No.
|
||||
This is pointless busywork that accomplishes nothing.
|
||||
Fetch the main repository directly as described above and base your feature branches on the refs from it.
|
||||
GitHub has no interface or functionality for keeping the branches in your fork up to date with the branches in the main repository and there is no point in doing this for our feature branch based workflow.
|
||||
|
||||
|
||||
Changelog
|
||||
---------
|
||||
## Changelog
|
||||
|
||||
There's a [Changelog](../CHANGELOG.md) in the root of the project. If
|
||||
you make changes that are visible to users of this project you should
|
||||
add an entry to the changelog describing what has changed. The format
|
||||
of this log should be self explanatory, please follow it carefully.
|
||||
There's a [Changelog](../CHANGELOG.md) in the root of the project.
|
||||
If you make changes that are visible to users of this project you should add an entry to the changelog describing what has changed.
|
||||
The format of this log should be self explanatory, please follow it carefully.
|
||||
|
||||
|
||||
Supported Node.js and Factorio Versions
|
||||
---------------------------------------
|
||||
## Supported Node.js and Factorio Versions
|
||||
|
||||
Clusterio run on Node.js v12 and up, v11.13.0+ and v10.16.0+, make sure
|
||||
to check that the Node builtin API's and npm packages you use are
|
||||
supported on these versions. The Travis CI tests are runned on whatever
|
||||
version of Node.js v10 is the default, which at the time of writing is
|
||||
v10.18.0.
|
||||
Clusterio run on Node.js v18 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
|
||||
The GitHub Actions tests are run on whatever version of Node.js v18 and v20 is the default.
|
||||
|
||||
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up,
|
||||
including the latest experimental release. It's recommended that you
|
||||
use the lua API reference for 0.17.69, as there's no information on what
|
||||
version Factorio API's were introduced in. The Travis CI tests are
|
||||
runned against latest experimental release.
|
||||
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
|
||||
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
|
||||
The GitHub Actions tests are run against the latest experimental release.
|
||||
|
||||
|
||||
Code Style
|
||||
----------
|
||||
## Code Style
|
||||
|
||||
The style of the code is a bit of a mixed bag at the moment. But there
|
||||
are at least a few things that have been agreed upon.
|
||||
The style of the code is a bit of a mixed bag at the moment.
|
||||
But there are at least a few things that have been agreed upon.
|
||||
|
||||
|
||||
### Naming style
|
||||
|
||||
JavaScript variables in general uses camelCase. The exception is for
|
||||
classes/constructors which use PascalCase and variables destructured
|
||||
from over the wire objects.
|
||||
JavaScript variables in general uses camelCase.
|
||||
The exception is for classes/constructors which use PascalCase and variables destructured from over the wire objects.
|
||||
|
||||
Fields in configs and messages sent over links uses
|
||||
lowercase_underscore.
|
||||
Fields in configs uses lowercase_underscore.
|
||||
|
||||
JavaScript files are named using lowercase_underscore for files that
|
||||
export multiple items. Files containing and exporting a single class
|
||||
should be named the same as the class in PascalCase.
|
||||
JavaScript files are named using lowercase_underscore for files that export multiple items.
|
||||
Files containing and exporting a single class should be named the same as the class in PascalCase.
|
||||
|
||||
Imported modules from the lib package are prefixed with lib to make them
|
||||
easier to distinguish, as the names tend to be very generic. E.g.:
|
||||
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
|
||||
E.g.:
|
||||
|
||||
const libLink = require("lib/link");
|
||||
const libLuaTools = require("lib/lua_tools");
|
||||
const libLink = require("./link");
|
||||
const libLuaTools = require("./lua_tools");
|
||||
|
||||
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
|
||||
|
||||
For lua code lowercase_underscore is used for everything.
|
||||
|
||||
### Units
|
||||
|
||||
When naming variables whos unit is not a base SI unit like seconds or metres the unit used should be suffixed in the variable name:
|
||||
E.g.:
|
||||
|
||||
const startTimeMs = date.now();
|
||||
const endNs = process.hrtime();
|
||||
|
||||
This makes it easier to spot different unit bases are being mixed in the code.
|
||||
|
||||
|
||||
### Strings
|
||||
|
||||
JavaScript and Lua code should use double quoted strings (`"string"`)
|
||||
for all strings with the only exception that strings that contain double
|
||||
quotes may use single quotes (i.e., `'String with "quotes"'`).
|
||||
JavaScript and Lua code should use double quoted strings (`"string"`) for all strings with the only exception that strings that contain double quotes may use single quotes (i.e., `'String with "quotes"'`).
|
||||
JavaScript template literals may also be used where appropriate.
|
||||
|
||||
|
||||
### Indenting
|
||||
|
||||
JavaScript code should be indented with tabs.
|
||||
JavaScript and Lua code should be indented with tabs.
|
||||
|
||||
Lua code should be indented with 4 spaces.
|
||||
Other files types should prefer tabs for indents where reasonable with the exception of Markdown.
|
||||
|
||||
|
||||
### Line Length
|
||||
|
||||
Lines are limited to 120 characters where tabs count as 4 characters.
|
||||
If the argument list for a function or expressino for compound statement
|
||||
gets very long break them out on their own line(s). For example:
|
||||
If the argument list for a function or expressino for compound statement gets very long break them out on their own line(s).
|
||||
For example:
|
||||
|
||||
function foo(
|
||||
really, long, argument, list,
|
||||
@@ -262,3 +262,63 @@ gets very long break them out on their own line(s). For example:
|
||||
// code
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
### Markdown
|
||||
|
||||
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
|
||||
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
|
||||
For example:
|
||||
|
||||
```md
|
||||
# A markdown File
|
||||
|
||||
Sentence of text which end in a period.
|
||||
Each sentence is on it's own line.
|
||||
And there's two spaces before the next heading.
|
||||
|
||||
|
||||
## Subheading
|
||||
|
||||
### Sub-subheading
|
||||
|
||||
Headings use the `# heading` format.
|
||||
```
|
||||
|
||||
|
||||
## Pushing releases to npm
|
||||
|
||||
The releases are done by updating packages versions and then publishing them with pnpm.
|
||||
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
|
||||
|
||||
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
|
||||
|
||||
```sh
|
||||
git switch master
|
||||
git pull --ff-only upstream master
|
||||
```
|
||||
|
||||
Check and make sure there are no untracked files with `git status`.
|
||||
|
||||
2. Update the version in package files.
|
||||
Make sure to update the command for the right version jump.
|
||||
|
||||
```sh
|
||||
sed -i 's/2\.0\.0-alpha\.12/2\.0\.0-alpha\.13/' {packages,plugins}/*/package.json
|
||||
```
|
||||
|
||||
3. Inspect, commit and tag the changes.
|
||||
Again, make sure to be tagging the right version.
|
||||
|
||||
```sh
|
||||
git add -p
|
||||
git commit -m v2.0.0-alpha.13
|
||||
git tag v2.0.0-alpha.13
|
||||
```
|
||||
|
||||
4. Publish the packages.
|
||||
|
||||
```sh
|
||||
rm -r ./{plugins,packages}/*/dist
|
||||
pnpm -r --filter='./{plugins,packages}/*' publish
|
||||
```
|
||||
@@ -1,13 +1,9 @@
|
||||
Developing for Clusterio
|
||||
========================
|
||||
# Developing for Clusterio
|
||||
|
||||
This document describes the various different types of content that can
|
||||
be created for Factorio and/or Clusterio and how this is made compatible
|
||||
with Clusterio.
|
||||
This document describes the various different types of content that can be created for Factorio and/or Clusterio and how this is made compatible with Clusterio.
|
||||
|
||||
|
||||
Contents
|
||||
--------
|
||||
## Contents
|
||||
|
||||
- [Factorio Mods](#factorio-mods)
|
||||
- [Factorio Scenarios](#factorio-scenarios)
|
||||
@@ -18,35 +14,21 @@ Contents
|
||||
- [Clusterio Plugins](#clusterio-plugins)
|
||||
|
||||
|
||||
Factorio Mods
|
||||
-------------
|
||||
## Factorio Mods
|
||||
|
||||
Factorio mods that do not interact with Clusterio should not have any
|
||||
considerations that need to be taken into account for them to work with
|
||||
Clusterio.
|
||||
Factorio mods that do not interact with Clusterio should not have any considerations that need to be taken into account for them to work with Clusterio.
|
||||
|
||||
|
||||
Factorio Scenarios
|
||||
------------------
|
||||
## Factorio Scenarios
|
||||
|
||||
Clusterio patches saves and because of this scenarios have a few
|
||||
limitations and must explicitly mark themselves as compatible with
|
||||
Clusterio to work. The main limitation is that the
|
||||
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
|
||||
lib must be used, and control.lua cannot contain any code other than
|
||||
calls to event_handler loading the relevant libs of the scenario.
|
||||
This is because the control.lua file will be overwritten by the save
|
||||
patcher. Scenarios also cannot use the event registering functions
|
||||
exposed by `script` such as `script.on_event` or `script.on_nth_tick` as
|
||||
using these will overwrite the handlers registered by the
|
||||
event_handler lib.
|
||||
Clusterio patches saves and because of this scenarios have a few limitations and must explicitly mark themselves as compatible with Clusterio to work.
|
||||
The main limitation is that the [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) lib must be used, and control.lua cannot contain any code other than calls to event_handler loading the relevant libs of the scenario.
|
||||
This is because the control.lua file will be overwritten by the save patcher.
|
||||
Scenarios also cannot use the event registering functions exposed by `script` such as `script.on_event` or `script.on_nth_tick` as using these will overwrite the handlers registered by the event_handler lib.
|
||||
|
||||
A brief description of the usage of the event_handler library is
|
||||
provided in the [event_handler interface](#event_handler-interface)
|
||||
section.
|
||||
A brief description of the usage of the event_handler library is provided in the [event_handler interface](#event_handler-interface) section.
|
||||
|
||||
Once a scenario is made using the event_handler it should have a
|
||||
control.lua file that looks something like this:
|
||||
Once a scenario is made using the event_handler it should have a control.lua file that looks something like this:
|
||||
|
||||
```lua
|
||||
-- control.lua
|
||||
@@ -55,8 +37,7 @@ event_handler.add_lib(require("module_foo"))
|
||||
event_handler.add_lib(require("module_bar"))
|
||||
```
|
||||
|
||||
To make Clusterio recognize and correctly handle this scenario it
|
||||
needs to include a clusterio.json file with following content:
|
||||
To make Clusterio recognize and correctly handle this scenario it needs to include a clusterio.json file with following content:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -70,60 +51,43 @@ needs to include a clusterio.json file with following content:
|
||||
}
|
||||
```
|
||||
|
||||
This tells Clusterio that it should load both module_foo and module_bar
|
||||
by passing the result of requiring them to the `add_lib` function when
|
||||
writing a new control.lua file to the save.
|
||||
This tells Clusterio that it should load both module_foo and module_bar by passing the result of requiring them to the `add_lib` function when writing a new control.lua file to the save.
|
||||
|
||||
|
||||
### event_handler interface
|
||||
|
||||
<sub>**Note:** At the time of writing the event_handler lib has been
|
||||
a part of Factorio since at least 0.17.4, and was moved from base to
|
||||
core in 0.17.63 but is still not documented anywhere. A brief
|
||||
description is provided here for this reason.</sub>
|
||||
<sub>**Note:** At the time of writing the event_handler lib has been a part of Factorio since at least 0.17.4, and was moved from base to core in 0.17.63 but is still not documented anywhere.
|
||||
A brief description is provided here for this reason.</sub>
|
||||
|
||||
The event_handler lib provides a simple interface for registering
|
||||
multiple callbacks to the same events without having to be concerned
|
||||
with callback chaining or overwriting callbacks defined elsewhere. It
|
||||
works by taking over the task of registering the actual callbacks with
|
||||
Factorio, providing its own interface for the rest of the code to use.
|
||||
The event_handler lib provides a simple interface for registering multiple callbacks to the same events without having to be concerned with callback chaining or overwriting callbacks defined elsewhere.
|
||||
It works by taking over the task of registering the actual callbacks with Factorio, providing its own interface for the rest of the code to use.
|
||||
|
||||
The sole function of interest exported by event_handler is `add_lib`
|
||||
which accepts a table with event handler callbacks definitions that it
|
||||
will register. The following entries are recognized by `add_lib`, all
|
||||
of which are optional:
|
||||
The sole function of interest exported by event_handler is `add_lib` which accepts a table with event handler callbacks definitions that it will register.
|
||||
The following entries are recognized by `add_lib`, all of which are optional:
|
||||
|
||||
- `on_init`:
|
||||
Callback called when the callback of `script.on_init` is invoked.
|
||||
- `on_load`:
|
||||
Callback called when the callback of `script.on_load` is invoked.
|
||||
- `on_configuration_changed`:
|
||||
Callback called when the callback of
|
||||
`script.on_configuration_changed` is invoked.
|
||||
Callback called when the callback of `script.on_configuration_changed` is invoked.
|
||||
- `events`:
|
||||
Table mapping event ids as defined in `defines.events` to callbacks.
|
||||
For example the table `{ [defines.events.on_player_died] = foo }`
|
||||
will call bar with the usual `event` table as argument every time a
|
||||
player dies.
|
||||
For example the table `{ [defines.events.on_player_died] = foo }` will call bar with the usual `event` table as argument every time a player dies.
|
||||
- `on_nth_tick`:
|
||||
Table mapping the nth tick number to a callback. For example the
|
||||
table `{ [30] = bar }` will call bar every 30th tick.
|
||||
Table mapping the nth tick number to a callback.
|
||||
For example the table `{ [30] = bar }` will call bar every 30th tick.
|
||||
- `add_remote_interface`:
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
|
||||
has no special meaning and receives no arguments but should be used
|
||||
for registering remote interfaces.
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
|
||||
It has no special meaning and receives no arguments but should be used for registering remote interfaces.
|
||||
- `add_commands`:
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
|
||||
has no special meaning and receives no arguments but should be used
|
||||
for registering commands.
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
|
||||
It has no special meaning and receives no arguments but should be used for registering commands.
|
||||
|
||||
<sub>1: Before 0.17.69 these callbacks were called after
|
||||
on_init/on_load.</sub>
|
||||
<sub>1: Before 0.17.69 these callbacks were called after on_init/on_load.</sub>
|
||||
|
||||
The usual way to use `add_lib` is to define the table of events to
|
||||
register in a separate file and return it, then load it in control.lua
|
||||
via `require` before passing it to `add_lib`. For example a module
|
||||
could be defined as
|
||||
The usual way to use `add_lib` is to define the table of events to register in a separate file and return it, then load it in control.lua via `require` before passing it to `add_lib`.
|
||||
For example a module could be defined as
|
||||
|
||||
```lua
|
||||
-- example.lua
|
||||
@@ -148,41 +112,27 @@ local event_handler = require("event_handler")
|
||||
event_handler.add_lib(require("example"))
|
||||
```
|
||||
|
||||
Because the event_handler lib registers events itself you may not use
|
||||
`script.on_load` or `script.on_init` at all in your code and any usage
|
||||
of `script.on_configuration_changed`, `script.on_nth_tick` and/or
|
||||
`script.on_event` will cause the corresponding events registered with
|
||||
event_handler to break and should therefore not be used.
|
||||
Because the event_handler lib registers events itself you may not use `script.on_load` or `script.on_init` at all in your code and any usage of `script.on_configuration_changed`, `script.on_nth_tick` and/or `script.on_event` will cause the corresponding events registered with event_handler to break and should therefore not be used.
|
||||
|
||||
There's also the `add_libraries` function exported by event_handler,
|
||||
which accepts a table and calls `add_lib` for each value in the table.
|
||||
There's also the `add_libraries` function exported by event_handler, which accepts a table and calls `add_lib` for each value in the table.
|
||||
|
||||
|
||||
Communicating with Clusterio
|
||||
----------------------------
|
||||
## Communicating with Clusterio
|
||||
|
||||
There's a poorly to virtuarly undocumented HTTP interface that can be
|
||||
used to communicate with Clusterio to do things like get the status of
|
||||
instances, grab items from the store, run commands on instances, etc,
|
||||
etc. For now you can read the source code, and maybe ask on the Discord
|
||||
for some pointers if you get stuck.
|
||||
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
|
||||
See [socket.md](socket.md) for the implementation details of it.
|
||||
|
||||
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
|
||||
|
||||
|
||||
Clusterio Modules
|
||||
-----------------
|
||||
## Clusterio Modules
|
||||
|
||||
Modules are primarily used by plugins to inject code into Factorio games
|
||||
with the save patcher, though it's also possible to make stand alone
|
||||
modules that are loaded into the game if you don't need the capabilites
|
||||
of the plugin system. The save patcher puts modules into the `modules`
|
||||
folder of the Factorio save and adds code to `control.lua` to load the
|
||||
module according to the `load` and `require` options to the module.json
|
||||
file.
|
||||
Modules are primarily used by plugins to inject code into Factorio games with the save patcher, though it's also possible to make stand alone modules that are loaded into the game if you don't need the capabilites of the plugin system.
|
||||
The save patcher puts modules into the `modules` folder of the Factorio save and adds code to `control.lua` to load the module according to the `load` and `require` options to the module.json file.
|
||||
Like with scenarios for Clusterio the [event_handler interface](#event_handler-interface) has to be used for any event subscriptions in modules.
|
||||
|
||||
Stand alone modules are placed into the modules folder of Clusterio,
|
||||
plugin modules are located in the module folder of the plugin. In
|
||||
either case a `module.json` file is required and has the following
|
||||
structure:
|
||||
Stand alone modules are placed into the modules folder of Clusterio, plugin modules are located in the module folder of the plugin.
|
||||
In either case a `module.json` file is required and has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -200,68 +150,44 @@ structure:
|
||||
The following entries are supported in the module.json file:
|
||||
|
||||
- `name`:
|
||||
Name of the module, must match the folder the module is located in
|
||||
for stand alone modules or the name of the plugin the module is
|
||||
located in.
|
||||
Name of the module, must match the folder the module is located in for stand alone modules or the name of the plugin the module is located in.
|
||||
- `version`:
|
||||
Version of the module. Must be compatible with [Semantic Versioning
|
||||
2.0.0](https://semver.org/). In plugin modules this defaults to the
|
||||
version of the plugin otherwise it's required.
|
||||
Version of the module.
|
||||
Must be compatible with [Semantic Versioning 2.0.0](https://semver.org/).
|
||||
In plugin modules this defaults to the version of the plugin otherwise it's required.
|
||||
- `dependencies`:
|
||||
Optional mapping of modules this module depends on and their
|
||||
version. In the example `>=0.4.2` means that the foo module must be
|
||||
at least version 0.4.2. See the [Ranges
|
||||
syntax](https://www.npmjs.com/package/semver#ranges) of the
|
||||
node-semver package for a full description of what operators are
|
||||
supported. The events for dependencies is invoked before the events
|
||||
of the dependent, and starting an instance will fail if the
|
||||
dependencies cannot be satisfied. If not specified it will default
|
||||
to depending on `clusterio`. Make sure to add `clusterio` to your
|
||||
dependencies if you add other dependencies and depend on the Lua API.
|
||||
Optional mapping of modules this module depends on and their version.
|
||||
In the example `>=0.4.2` means that the foo module must be at least version 0.4.2.
|
||||
See the [Ranges syntax](https://www.npmjs.com/package/semver#ranges) of the node-semver package for a full description of what operators are supported.
|
||||
The events for dependencies is invoked before the events of the dependent, and starting an instance will fail if the dependencies cannot be satisfied.
|
||||
If not specified it will default to depending on `clusterio`.
|
||||
Make sure to add `clusterio` to your dependencies if you add other dependencies and depend on the Lua API.
|
||||
- `require`:
|
||||
Lua files to require in `control.lua`. This should only really be
|
||||
needed if you want to make a global function available for use in
|
||||
Lua commands. The paths are relative to the module's own folder and
|
||||
should be specified using forward slashes as directory sepparators
|
||||
if it's located in a sub directory in the module.
|
||||
Lua files to require in `control.lua`.
|
||||
This should only really be needed if you want to make a global function available for use in Lua commands.
|
||||
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
|
||||
- `load`:
|
||||
Lua files to load with the `event_handler` lib, the result of
|
||||
requiring the file will be passed to the `add_lib` function of the
|
||||
`event_handler` lib in `control.lua`. See the section on the
|
||||
[event_handler interface](#event_handler-interface) for a detailed
|
||||
description on how the `event_handler` lib works. The paths are
|
||||
relative to the module's own folder and should be specified using
|
||||
forward slashes as directory sepparators if it's located in a sub
|
||||
directory in the module.
|
||||
Lua files to load with the `event_handler` lib, the result of requiring the file will be passed to the `add_lib` function of the `event_handler` lib in `control.lua`.
|
||||
See the section on the [event_handler interface](#event_handler-interface) for a detailed description on how the `event_handler` lib works.
|
||||
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
|
||||
|
||||
It's recommended to use the new style of defining modules in Lua, as
|
||||
well as avoid the use of global variables as much as possible. This
|
||||
means always declearing your top level variables and functions local and
|
||||
exporting only the things you need in other files. You can require
|
||||
other files in your module by prefixing your require paths with
|
||||
`modules/your_module_name/`. It's also possible to require files from
|
||||
other modules this way too.
|
||||
It's recommended to use the new style of defining modules in Lua, as well as avoid the use of global variables as much as possible.
|
||||
This means always declearing your top level variables and functions local and exporting only the things you need in other files.
|
||||
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
|
||||
It's also possible to require files from other modules this way too.
|
||||
|
||||
The global variables and data stored in the global table is shared by
|
||||
all Clusterio modules as well as the scenario, so it's important that
|
||||
you use unique names. It's recommended to prefix the global variables
|
||||
and data in the global table that your module has with the name of your
|
||||
module.
|
||||
The global variables and data stored in the global table is shared by all Clusterio modules as well as the scenario, so it's important that you use unique names.
|
||||
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
|
||||
|
||||
Because modules can be patched into an existing game you cannot rely on
|
||||
the `on_init` callback to be called in Clusterio Modules. Nor can you
|
||||
rely on the `on_configuration_changed` callback, as this is not called
|
||||
when level code changes. The Clusterio Lua API provides the custom
|
||||
`on_server_startup` event that can be used as a substitute, see the next
|
||||
section.
|
||||
Because modules can be patched into an existing game you cannot rely on the `on_init` callback to be called in Clusterio Modules.
|
||||
Nor can you rely on the `on_configuration_changed` callback, as this is not called when level code changes.
|
||||
The Clusterio Lua API provides the custom `on_server_startup` event that can be used as a substitute, see the next section.
|
||||
|
||||
|
||||
Clusterio Lua API
|
||||
-----------------
|
||||
## Clusterio Lua API
|
||||
|
||||
Clusterio provides a save patched module as well as a regular Factorio
|
||||
mod for interfacing with Clusterio. This includes tools sending data to
|
||||
plugins, script events to listen for, and an item serialization module.
|
||||
Clusterio provides a save patched module as well as a regular Factorio mod for interfacing with Clusterio.
|
||||
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
|
||||
|
||||
|
||||
### clusterio_api library
|
||||
@@ -272,30 +198,27 @@ From a module
|
||||
From a mod
|
||||
`local clusterio_api = require("__clusterio_lib__/api")`
|
||||
|
||||
Provides the main interface to Clusterio from within the game. When
|
||||
using the mod version you must call `init` before it'll function
|
||||
properly.
|
||||
Provides the main interface to Clusterio from within the game.
|
||||
When using the mod version you must call `init` before it'll function properly.
|
||||
|
||||
#### init
|
||||
|
||||
<sub>Mod version only</sub>
|
||||
|
||||
Initialize the API. This should be called from both the `on_init` and
|
||||
`on_load` events in your mod, and before this is called certain features
|
||||
will not be available, most notably the events table.
|
||||
Initialize the API.
|
||||
This should be called from both the `on_init` and `on_load` events in your mod, and before this is called certain features will not be available, most notably the events table.
|
||||
|
||||
#### clusterio_api.events
|
||||
|
||||
Table of events raised by clusterio. If the game is started outside of
|
||||
Clusterio then none of these events will be raised.
|
||||
Table of events raised by clusterio.
|
||||
If the game is started outside of Clusterio then none of these events will be raised.
|
||||
|
||||
In the mod version this table does not exist before the `init` function
|
||||
is called.
|
||||
In the mod version this table does not exist before the `init` function is called.
|
||||
|
||||
##### on_instance_updated
|
||||
|
||||
Raised after the name and id of an instance has been updated. This may
|
||||
occur even if the id and name didn't change.
|
||||
Raised after the name and id of an instance has been updated.
|
||||
This may occur even if the id and name didn't change.
|
||||
|
||||
Event data:
|
||||
- `instance_id`: the id of the instance.
|
||||
@@ -303,33 +226,26 @@ Event data:
|
||||
|
||||
##### on_server_startup
|
||||
|
||||
Raised when Clusterio is starting up the server. It can be used to
|
||||
initialize clusterio related features in a mod, and as a stand-in for
|
||||
the `on_init` and `on_configuration_changed` event for modules. It is
|
||||
invoked on the first tick the server runs after the save has been
|
||||
patched, before most other events.
|
||||
Raised when Clusterio is starting up the server.
|
||||
It can be used to initialize clusterio related features in a mod, and as a stand-in for the `on_init` and `on_configuration_changed` event for modules.
|
||||
It is invoked on the first tick the server runs after the save has been patched, before most other events.
|
||||
|
||||
Use this event to initialize and/or migrate the data structures
|
||||
you need. Keep in mind that both mods and Clusterio modules can switch
|
||||
from any version to any version so it should be able to handle both
|
||||
forwards and backwards migrations gracefully.
|
||||
Use this event to initialize and/or migrate the data structures you need.
|
||||
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
|
||||
|
||||
#### clusterio_api.send_json(channel, data)
|
||||
|
||||
Send JSON data to Clusterio over the given channel. The `data` argument
|
||||
should be table that can be serialized with `game.table_to_json`.
|
||||
Clusterio plugins can listen to channels and will receive an event with
|
||||
the data sent here. See the [Communicating with Factorio
|
||||
section](writing-plugins.md#communicating-with-factorio) in the Writing
|
||||
Plugins document for more information.
|
||||
Send JSON data to Clusterio over the given channel.
|
||||
The `data` argument should be table that can be serialized with `game.table_to_json`.
|
||||
Clusterio plugins can listen to channels and will receive an event with the data sent here.
|
||||
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
|
||||
|
||||
If the game is started outside of Clusterio the data will be sent, but
|
||||
since there's no code following stdout to pick it up it will be lost.
|
||||
If the game is started outside of Clusterio the data will be sent, but since there's no code following stdout to pick it up it will be lost.
|
||||
|
||||
**Note**: Payloads greater than 4 MB will cause stuttering in the game.
|
||||
|
||||
**Note**: This is not a binary safe way of sending data. Strings
|
||||
embedded into the tables sent must be valid UTF-8 text.
|
||||
**Note**: This is not a binary safe way of sending data.
|
||||
Strings embedded into the tables sent must be valid UTF-8 text.
|
||||
|
||||
Parameters:
|
||||
- `channel`: string identifying which channel to send it on.
|
||||
@@ -344,19 +260,17 @@ From a module
|
||||
From a mod
|
||||
`local serialize = require("__clusterio_lib__/serialize")`
|
||||
|
||||
Serializes inventories and item stacks into a table that in turn can be
|
||||
converted into JSON with `game.table_to_json`. Properly handles the
|
||||
extra data for most items.
|
||||
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `game.table_to_json`.
|
||||
Properly handles the extra data for most items.
|
||||
|
||||
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
|
||||
|
||||
Returns a table containing a serialized representation of the equipment
|
||||
grid passed.
|
||||
Returns a table containing a serialized representation of the equipment grid passed.
|
||||
|
||||
#### serialize.deserialize_equipment_grid(LuaEquipmentGrid, serialized_grid)
|
||||
|
||||
Deserialize a previously serialized equipment grid into the target
|
||||
LuaEquipmentGrid. Overwrites all content in the target.
|
||||
Deserialize a previously serialized equipment grid into the target LuaEquipmentGrid.
|
||||
Overwrites all content in the target.
|
||||
|
||||
#### serialize.serialize_item_stack(LuaItemStack, destination_table)
|
||||
|
||||
@@ -365,23 +279,19 @@ The destination_table should be a new empty table.
|
||||
|
||||
#### serialize.deserialize_item_stack(LuaItemStack, serialized_stack)
|
||||
|
||||
Deserialize a previously serialized item stack into the target
|
||||
LuaItemStack. Overwrites the content of the item stack.
|
||||
Deserialize a previously serialized item stack into the target LuaItemStack.
|
||||
Overwrites the content of the item stack.
|
||||
|
||||
#### serialize.serialize_inventory(LuaInventory)
|
||||
|
||||
Returns a table containing a serialized representation of the inventory
|
||||
passed.
|
||||
Returns a table containing a serialized representation of the inventory passed.
|
||||
|
||||
#### serialize.deserialize_inventory(LuaInventory, serialized_inventory)
|
||||
|
||||
Deserialize a previously serialized inventory into the target
|
||||
LuaInventory. Overwrites slots that have content in the serialized
|
||||
inventory.
|
||||
Deserialize a previously serialized inventory into the target LuaInventory.
|
||||
Overwrites slots that have content in the serialized inventory.
|
||||
|
||||
|
||||
Clusterio Plugins
|
||||
-----------------
|
||||
## Clusterio Plugins
|
||||
|
||||
See the [Writing Plugins](writing-plugins.md) document for details on
|
||||
the plugin interface and how to write plugins for Clusterio.
|
||||
See the [Writing Plugins](writing-plugins.md) document for details on the plugin interface and how to write plugins for Clusterio.
|
||||
@@ -0,0 +1,119 @@
|
||||
# JSON Serialisable Classes
|
||||
|
||||
To serialise and deserialise data the Clusterio codebase uses a JSON serialisable data class pattern.
|
||||
Instances of these classes can be serialised to JSON by passing them directly to `JSON.serialise` and deserialised by passing the serialised JSON representation to its static `fromJSON` method.
|
||||
The data class also has a static `jsonSchema` property describing the serialised JSON representation for validation purposes.
|
||||
See the following example code for a hypothetical `Data` class with a single property `member` defaulting to a value of `"foo"`.
|
||||
|
||||
|
||||
```js
|
||||
// Data.js
|
||||
"use strictc";
|
||||
|
||||
/**
|
||||
* A Data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
class Data {
|
||||
/** @type {string} */
|
||||
member = "foo";
|
||||
|
||||
constructor(member) {
|
||||
if (member !== undefined) { this.member = member; }
|
||||
}
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
"member": { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.member);
|
||||
}
|
||||
|
||||
// toJSON is called by JSON.stringify when serialising this class
|
||||
toJSON() {
|
||||
let json = {};
|
||||
// Omit property if it's set to the default value
|
||||
if (this.member !== "foo") { json["member"] = this.member; }
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Data;
|
||||
```
|
||||
|
||||
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
|
||||
Deserialising is done by calling the static `fromJSON` method with the serialised JSON data, like `data = Data.fromJSON(JSON.parse(serialised))`.
|
||||
|
||||
By structuring the classes like this they can be easily composed together.
|
||||
For example a class holding a data property with an instance of the Data class from the previous example can be created as follows.
|
||||
|
||||
```js
|
||||
// Composed.js
|
||||
"use strict";
|
||||
const Data = require("./Data");
|
||||
|
||||
/**
|
||||
* Composed data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
class Composed {
|
||||
/** @type {module:Data|undefined} */
|
||||
data;
|
||||
|
||||
constructor(data) {
|
||||
if (data) { this.data = data; }
|
||||
}
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
"data": Data.jsonSchema,
|
||||
},
|
||||
};
|
||||
|
||||
static fromJSON(json) {
|
||||
let data;
|
||||
if (json["data"]) { data = Data.fromJSON(json["data"]); }
|
||||
return new this(data);
|
||||
}
|
||||
|
||||
// toJSON is optional and not needed for this particular class
|
||||
}
|
||||
|
||||
module.exports = Composed;
|
||||
```
|
||||
|
||||
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
|
||||
Meaning `serialised = JSON.stringify(composed)` and `composed = Composed.fromJSON(JSON.parse(serialised))` does as expected.
|
||||
|
||||
## Interface description
|
||||
|
||||
Data classes are ES6 style class declarations with the following methods and fields:
|
||||
|
||||
### toJSON(): any (optional)
|
||||
|
||||
Method returning an object or primitive suitable for passing to JSON.stringify in order to create a JSON representation of the class instance.
|
||||
The returned object may contain objects that have their own toJSON methods which JSON.stringify will then call recursively.
|
||||
|
||||
This method is optional if calling `JSON.stringify` on an instance without it would produce the desired serialised form, however this is rarely the case as often you'll have to provide it to convert the values fram Map/Set properites, omit default values or do similar actions.
|
||||
|
||||
Note that the primary use case for serialising data classes is to send them over the network, this method should therefore try to reduce the size of the resulting JSON by for example omitting properties who's values are the default value.
|
||||
|
||||
### static fromJSON(json: any): this
|
||||
|
||||
Method constructing an instance of this class from one that was previously serialised by passing an instance of it to `JSON.stringify`.
|
||||
|
||||
While not a requirement it is preferrable if the input is not mutated and that no references to any any structures in the input ends up in the returned object.
|
||||
In other words calling fromJSON twice with the same input should produce two independent objects.
|
||||
|
||||
Note that directly passing the output of `toJSON` to `fromJSON` is not valid, as the output of `toJSON` may require further expansion calling `toJSON` on objects returned by it.
|
||||
It has to go through `JSON.stringify` or an equivalent recursive expansion of `toJSON`.
|
||||
|
||||
### static jsonSchema
|
||||
|
||||
A [JSON schema](http://json-schema.org/) describing the format of the JSON resulting from passing an instance of this class to `JSON.stringify`.
|
||||
This should be lenient towards adding additional properties that would be ignored by `fromJSON` as it makes it easier to extend these classes in the future without breaking compatibility.
|
||||
@@ -0,0 +1,523 @@
|
||||
# Migrating Plugins to Alpha 14
|
||||
|
||||
Between version 2.0.0-alpha.13 and 2.0.0-alpha.14 the codebase underwent major refactoring and was ported to TypeScript.
|
||||
In order to support alpha 14 there are several major changes that need to be done to existing plugins, these are in no particular order
|
||||
|
||||
|
||||
## Rename master and slave
|
||||
|
||||
All references to slave and master in the code was renamed to controller and host respectively which means that code referencing these in plugins must be renamed.
|
||||
In short this means performing a global search and replace on the codebase replacing:
|
||||
|
||||
- `master` with `controller`
|
||||
- `Master` with `Controller`
|
||||
- `slave` with `host`
|
||||
- `Slave` with `Host`
|
||||
|
||||
in all places it occurs, even as part of longer names such as changing `BaseMasterPlugin` to `BaseControllerPlugin`.
|
||||
|
||||
|
||||
## Change lib imports to import directly from the top level package
|
||||
|
||||
Previously code could import sub packages of lib such as lib/lua_tools.
|
||||
These imports need to be changed to all import from lib directly.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
// Using imports
|
||||
new libErrors.RequestError(...)
|
||||
libLuaTools.escapeString(...)
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
// Using library components
|
||||
new lib.RequestError(...)
|
||||
lib.escapeString(...)
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
// Using library components
|
||||
new lib.RequestError(...)
|
||||
lib.escapeString(...)
|
||||
```
|
||||
|
||||
|
||||
## Change import of base plugins
|
||||
|
||||
Previously `BaseMasterPlugin` and `BaseInstancePlugin` were imported from `@clusterio/lib`, these have been moved to `@clusterio/controller` and `@clusterio/host` respectively.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const { BaseMasterPlugin } = require("@clusterio/lib");
|
||||
const { BaseInstancePlugin } = require("@clusterio/lib");
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
```
|
||||
|
||||
|
||||
## Change main entrypoint of plugin
|
||||
|
||||
Previously the info of a plugin would be loaded by requiring `./plugin_dir/info`, this has been changed to require `./plugin_dir` which in turn means that `info.js` is no longer the default entrypoint.
|
||||
To solve this you can either add `"main": "info.js",` to package.json or rename `info.js` to `index.js` (the rest of this guide assumes the entrypoint was renamed to `index.js`).
|
||||
The motivation behind this change was to allow TypeScript to output its build to a sub-folder of the plugin, if you're using TypeScript you'll likely have the entrypoint be located in `dist/node/index.js` instead.
|
||||
|
||||
The expected place where the declaration of a plugin is exported has also changed from `module.exports` to `module.exports.plugin`:
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
// info.js
|
||||
module.exports = {
|
||||
name: "plugin_name",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
// index.js
|
||||
const plugin = {
|
||||
name: "plugin_name",
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
// index.ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "plugin_name",
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
### Modify exposed path in webpack.config.js
|
||||
|
||||
The `./info` import path exposed in the ModuleFederationPlugin in the `webpack.config.js` needs to be changed to `./`.
|
||||
E.g.:
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
// In webpack.config.js
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
...
|
||||
exposes: {
|
||||
"./info": "./info.js",
|
||||
...
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
**Alpha 14**
|
||||
```js
|
||||
// In webpack.config.js
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
...
|
||||
exposes: {
|
||||
"./": "./index.js",
|
||||
...
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
## Remove usage of ConfigGroup
|
||||
|
||||
The Config system was refactored and the `ConfigGroup` concept was completely removed.
|
||||
Config fields are now defined directly on the exported plugin info.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
class MasterConfigGroup extends lib.PluginConfigGroup { }
|
||||
MasterConfigGroup.groupName = "plugin_name";
|
||||
MasterConfigGroup.defaultAccess = ["master", "control"];
|
||||
MasterConfigGroup.define({
|
||||
name: "option",
|
||||
title: "Option",
|
||||
description: "A config option.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
name: "plugin_name",
|
||||
MasterConfigGroup,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const plugin = {
|
||||
name: "plugin_name",
|
||||
controllerConfigFields: {
|
||||
"plugin_name.option": {
|
||||
title: "Option",
|
||||
description: "A config option.",
|
||||
type: "string",
|
||||
initial_value: "foo",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
// Declare the type of the field to make the type of controller.config.get(field) correctly deduced.
|
||||
declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"plugin_name.option": null | string;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "plugin_name",
|
||||
controllerConfigFields: {
|
||||
"plugin_name.option": {
|
||||
title: "Option",
|
||||
description: "A config option.",
|
||||
type: "string",
|
||||
initialValue: "foo",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
### Use onControllerConfigFieldChanged instead of listering to the fieldChanged event
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
this.controller.config.on("fieldChanged", (group, field, prev) => {
|
||||
if (group.name === "plugin_name" && field === "option") {
|
||||
const curr = group.get(field);
|
||||
// Do stuff with curr and or prev
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
async onControllerConfigFieldChanged(field, curr, prev) {
|
||||
if (field === "plugin_name.option") {
|
||||
// Do stuff with curr and or prev
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
if (field === "plugin_name.option") {
|
||||
// Do stuff with curr and or prev
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Redefine link messages as classes
|
||||
|
||||
The link layer has been majorly refactored and Request and Events are now defined as [JSON Serialisable Classes](./json-serialisable-classes.md).
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
module.exports = {
|
||||
name: "plugin_name",
|
||||
messages: {
|
||||
forwarded: new lib.Event({
|
||||
type: "plugin_name:forwarded",
|
||||
links: ["instance-slave", "slave-master"],
|
||||
forwardTo: "master",
|
||||
eventProperties: {
|
||||
"string_value": { type: "string" },
|
||||
"number_value": { type: "number" },
|
||||
},
|
||||
}),
|
||||
simple: new lib.Request({
|
||||
type: "plugin_name:simple",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
forwardTo: "instance",
|
||||
requestProperties: {
|
||||
"instance_id": { type: "number" },
|
||||
"string_value": { type: "string" },
|
||||
},
|
||||
responseProperties: {
|
||||
"number_value": { type: "number" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
class ForwardedEvent {
|
||||
static type = "event";
|
||||
static src = "instance";
|
||||
static dst = "controller";
|
||||
static plugin = "plugin_name";
|
||||
|
||||
constructor(stringValue, numberValue) {
|
||||
this.stringValue = stringValue;
|
||||
this.numberValue = numberValue;
|
||||
}
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
required: ["stringValue", "numberValue"],
|
||||
properties: {
|
||||
"stringValue": { type: "string" },
|
||||
"numberValue": { type: "number" },
|
||||
},
|
||||
};
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.stringValue, json.numberValue);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleRequest {
|
||||
static type = "request";
|
||||
static src = "controller";
|
||||
static dst = "instance";
|
||||
static plugin = "plugin_name";
|
||||
|
||||
constructor(stringValue) {
|
||||
this.stringValue = stringValue;
|
||||
}
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
required: ["stringValue"],
|
||||
properties: {
|
||||
"stringValue": { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
static Response = lib.JsonNumber
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
name: "plugin_name",
|
||||
messages: [
|
||||
ForwardedEvent,
|
||||
SimpleRequest,
|
||||
],
|
||||
...
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
ForwardedEvent,
|
||||
SimpleRequest,
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
export class ForwardedEvent {
|
||||
declare ["constructor"]: typeof ForwardedEvent;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "plugin_name" as const;
|
||||
|
||||
constructor(
|
||||
public stringValue: string,
|
||||
public numberValue: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"stringValue": Type.String(),
|
||||
"numberValue": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.stringValue, json.numberValue);
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleRequest {
|
||||
declare ["constructor"]: typeof SimpleRequest;
|
||||
static type = "request" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "instance" as const;
|
||||
static plugin = "plugin_name" as const;
|
||||
|
||||
constructor(
|
||||
public stringValue: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"stringValue": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.stringValue);
|
||||
}
|
||||
|
||||
static Response = lib.JsonNumber
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "plugin_name",
|
||||
messages: [
|
||||
ForwardedEvent,
|
||||
SimpleRequest,
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Refactor message sending
|
||||
|
||||
Message handlers are now explicitly registered instead of relying on magic name lookups that don't make sense unless you know them.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
class ControllerPlugin extends lib.BaseControllerPlugin {
|
||||
// event name is magically created by concatenating EventHandler or
|
||||
// RequestHandler to the name in the messages export in in info.js.
|
||||
async forwardedEventHandler(message) {
|
||||
// message.data contains the raw JSON data sent.
|
||||
if (message.data.string_value) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/lib");
|
||||
const { ForwardedEvent } = require("./index.js");
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
// Explicit binding between message class and the handler for it
|
||||
this.controller.handle(ForwardedEvent, this.handleForwardedEvent.bind(this));
|
||||
// Note: On an instance plugin you would use this.instance.handle
|
||||
}
|
||||
|
||||
async handleForwardedEvent(event, src, dst) {
|
||||
// Event is an instance of the ForwardedEvent class
|
||||
if (event.stringValue) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// src and dst are instances of lib.Address and describe where
|
||||
// the message came from and who it was addressed to.
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import { ForwardedEvent } from "./index.js";
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
// When using TypeScript this call is type checked to make sure the handler accepts the right class.
|
||||
this.controller.handle(ForwardedEvent, this.handleForwardedEvent.bind(this));
|
||||
// Note: On an instance plugin you would use this.instance.handle
|
||||
}
|
||||
|
||||
async handleForwardedEvent(event: ForwardedEvent, src: lib.Address, dst: lib.Address) {
|
||||
// Event is an instance of the ForwardedEvent class
|
||||
if (event.stringValue) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// src and dst describe where the message came from and who it was addressed to.
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Refactor message sending
|
||||
|
||||
Message are now addressed end to end instead of using the confusing forwardTo and broadcastTo properties of the message definition.
|
||||
When broadcasting events the special `"allInstances"`, `"allHosts"`, and `"allControls"` shorthand addresses can be used.
|
||||
There's two methods for sending messages `.send` and `.sendTo` where `.send` is a shorthand for `.sendTo` with an address filled in as the other side of the link, this is mostly only useful in control plugins where the other side of the link is the controller.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
// from instance plugin
|
||||
this.info.messages.forwarded.send(this.instance, {
|
||||
string_value: "foo",
|
||||
number_value: 123,
|
||||
});
|
||||
|
||||
// from master plugin
|
||||
const instance_id = ...
|
||||
const response = await this.forwardRequestToInstance(this.info.messages.simple, {
|
||||
instance_id,
|
||||
string_value: "foo",
|
||||
});
|
||||
const value = response.number_value;
|
||||
```
|
||||
|
||||
**Alpha 14**
|
||||
```js
|
||||
const { ForwardedEvent } = require("./info.js");
|
||||
|
||||
// from instance plugin
|
||||
this.instance.sendTo("controller", new ForwardedEvent("foo", 123));
|
||||
|
||||
// from controller plugin
|
||||
const instanceId = ...
|
||||
const value = await this.controller.sendTo({ instanceId }, new SimpleRequest("foo"));
|
||||
// if using TypeScript then the type of value will be inferred as number here
|
||||
```
|
||||
@@ -0,0 +1,263 @@
|
||||
# Protocol
|
||||
|
||||
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
|
||||
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
|
||||
|
||||
Two general kinds of messages are supported: events and requests.
|
||||
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
|
||||
Requests are pairs of request-response messages where one side sends the requests and the other side replies with a response.
|
||||
Apart from the handshake used to establish connections the protocol is directionless, and either party can send or receive both requests and events.
|
||||
|
||||
Events and requests are defined as instances of the `Request` and `Event` classes, and specify a unique type string, who can send and receive the message, optional forwarding of the message and the format of the data payload as a JSON schema.
|
||||
All messages sent and received are validated with JSON schemas to catch potential errors in sending messages and avoid having to write lots of validation code in message handlers.
|
||||
|
||||
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
|
||||
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
|
||||
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
|
||||
For ctl and web_ui there's a `Control` instance that acts as the link to the controller.
|
||||
|
||||
Messages received invokes message handlers that were registered when the link was brought up.
|
||||
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
|
||||
The handlers are defined on the link class itself for core messages, and on the plugin class in the case of plugin defined messages.
|
||||
|
||||
|
||||
## Connection flow
|
||||
|
||||
Clients connect via WebSocket to the `/api/socket` endpoint and upon establishing connection to the server the connection is in the handshake phase and the server will send a `hello` message to the client.
|
||||
If the client has no previous session with the server it sends a `register_host` or `register_control` message depending on what kind of client it is to the server.
|
||||
If the register is valid then the server will respond with a `ready` message with a session token and the connection has left the handshake phase, otherwise the server will close the connection with a WebSocket close code indicating the kind of failure.
|
||||
|
||||
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
|
||||
Messages sent over the link should be stored in a send buffer and only cleared from the send buffer when a heartbeat with a `last_seq` equal or or higher is received or the session is invalidated.
|
||||
|
||||
If the connection goes stale or is unexpectedly closed the client can reastablish the same session by connecting to the server again and sending a `resume` message after received the `hello` from the server.
|
||||
If the session resume is successful then the server will respond with a `continue` message and the connection is active again.
|
||||
The client should then re-send all messages it has in its send buffer that is greater than the `last_seq` number in the `continue` message.
|
||||
Otherwise the server will respond with `invalidate` and the client is expected to drop its send buffer start a new session with `register_host` or `register_control`.
|
||||
|
||||

|
||||
|
||||
|
||||
## Connector flow
|
||||
|
||||
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
|
||||
There's three kinds of connectors defined: `WebSocketClientConnector`, `WebSocketServerConnector` and `VirtualConnector`.
|
||||
The virtual connector is for in-memory connections and doesn't have any connection state logic.
|
||||
|
||||
For the WebSocket connectors there's four states they can exist in:
|
||||
- `closed` - The connector has just been created or has been explicitly closed.
|
||||
- `connecting` (client only) - The connector is in the process of trying to connect to the server.
|
||||
- `connected` - The connector is successfully connected.
|
||||
- `resuming` - The connection dropped and the connector is in the process of attempting to resume it.
|
||||
|
||||
Along with the following events emitted when transition between states:
|
||||
- `connect` From `connecting` (client) or `closed` (server) to `connected`
|
||||
- `drop` - From `connected` to `resuming`
|
||||
- `resume` - From `resuming` to `connected`
|
||||
- `invalidate` - From `resuming` to `connecting` or `closed`
|
||||
- `close` - From any state to `closed`
|
||||
|
||||

|
||||
|
||||
|
||||
### WsClientConnector
|
||||
|
||||
The client connector will keep trying to establish and mantain a connection after `.connect()` is called on it, stopping only in the case of authentication failure or `.close()`/`.setClosing()` being called.
|
||||
Other than that, the logic is pretty straight forward.
|
||||
|
||||

|
||||
|
||||
|
||||
### WsServerConnector
|
||||
|
||||
The server connector doesn't do the work of connecting or validating the handshake.
|
||||
Instead it's handed over a WebSocket connection from `WsServer` once there's only the step of sending the final message left to do.
|
||||
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
|
||||
|
||||

|
||||
|
||||
|
||||
## Wire format
|
||||
|
||||
All communication in the protocol is done via WebSocket text frames containing a JSON payload with the following properties:
|
||||
|
||||
- `seq: integer | null` - The sequence number of the message, can only be null for handshake and heartbeats.
|
||||
- `type: string` - The type of message
|
||||
- `data: object` - Message data
|
||||
|
||||
Before a connection is established it's in the handshake phase and the `seq` property is `null`.
|
||||
The `type` property is used to distinguish which of handshake, heartbeat, events or requests this message is and determines the expected format of the `data` property.
|
||||
|
||||
|
||||
### Handshake Messages
|
||||
|
||||
The following messages are the only valid messages that can be sent during the handshake phase.
|
||||
|
||||
|
||||
#### `hello`
|
||||
|
||||
Sent by the controller when the WebSocket connection has been opened.
|
||||
|
||||
- `version: string` - The version of the controller, e.g. "2.0.0".
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are loaded on the controller.
|
||||
|
||||
#### `register_host`
|
||||
|
||||
Host handshake for establishing a new connection session.
|
||||
|
||||
- `token: string` - Authentication token.
|
||||
- `version: string` - The protocol version of the host, e.g. "2.0.0".
|
||||
- `id: integer` - ID of the host.
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are available on the host.
|
||||
|
||||
#### `register_control`
|
||||
|
||||
Control handshake for enstablishing a new connection session.
|
||||
|
||||
- `token: string` - Authentication token.
|
||||
- `version: string` - The protocol version of the control, e.g. "2.0.0".
|
||||
|
||||
#### `resume`
|
||||
|
||||
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
|
||||
|
||||
- `session_token: string` - Session token to resume session with.
|
||||
- `last_seq: integer | null` - Last message seq received by the client.
|
||||
|
||||
#### `invalidate`
|
||||
|
||||
Sent by the controller in reply to a resume for a session that cannot be resumed.
|
||||
The client should start a new session with a register\_\* message in response.
|
||||
|
||||
#### `continue`
|
||||
|
||||
Sent by the controller to signal resuming the session was successful.
|
||||
|
||||
- `last_seq: integer | null` - Last message seq received by the server.
|
||||
- `session_timeout: integer` - Session timeout in seconds.
|
||||
- `heartbeat_interval: number` - Rate client should send heartbeats at.
|
||||
|
||||
#### `ready`
|
||||
|
||||
Sent by the controller to signal the connection has been established with a new session.
|
||||
|
||||
- `session_token: string` - Token to resume the session with.
|
||||
- `session_timeout: integer` - Session timeout in seconds.
|
||||
- `heartbeat_interval: number` - Rate client should send heartbeats at.
|
||||
|
||||
|
||||
### Heartbeat message
|
||||
|
||||
Once connected as indicated by the `ready` and `continue` messages both sides of the connection starts sending heartbeats at the inverval given in the `ready` and `continue` messages.
|
||||
The heartbeat should contain the `seq` of the last received message, and receiving a heartbeat with a given sequence number means that the other party of the link has received all messages up to that seq number.
|
||||
|
||||
|
||||
#### `heartbeat`
|
||||
|
||||
Sent by both the controller and clients at the interval given in the continue/ready messages.
|
||||
|
||||
- `last_seq: integer` - Last message seq received over the link.
|
||||
|
||||
### Events
|
||||
|
||||
Events are messages send in one direction over the link, that the receiver is expected to act upon, but not make any replies (for that there's [Requests](#requests).
|
||||
By convention message types ending in `*_event` are events.
|
||||
The data payload of an event consists solely of event specific properties.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
|
||||
Plugins may defined their own events as well.
|
||||
|
||||
#### `*_event`
|
||||
|
||||
- any properties defined by the event type.
|
||||
|
||||
|
||||
### Requests
|
||||
|
||||
Requests are messages which expects a response in return.
|
||||
They function a lot like HTTP requests except that both parties of a connection can inniate a request that is expected to be responded to.
|
||||
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
|
||||
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
|
||||
Plugins may define their own requests as well.
|
||||
|
||||
#### `*_request`
|
||||
|
||||
- any properties defined by the request type.
|
||||
|
||||
#### `*_response` success
|
||||
|
||||
- `seq: integer` - The seq number of the request message.
|
||||
- any additional properties defined by the request type.
|
||||
|
||||
#### `*_response` error
|
||||
|
||||
- `seq: integer` - The seq number of the request message.
|
||||
- `error: string` - Human readable text message describing the error.
|
||||
|
||||
|
||||
### Graceful Disconnect
|
||||
|
||||
To ensure no data is lost when closing the link it's important to send a prepare disconnect request and wait for it to complete before closing the WebSocket.
|
||||
Either party of the link can send this request and the party sending the request should be the one that initates the close of the WebSocket.
|
||||
|
||||
#### `prepare_disconnect_request`
|
||||
|
||||
Request graceful disconnect of the connection.
|
||||
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
|
||||
Only after this process is done should the response be sent.
|
||||
|
||||
#### `prepare_disconnect_response`
|
||||
|
||||
Signals the preparation for disconnect is complete and that the connection can be closed.
|
||||
The party that initiatied the prepare disconnect request may still send other requests/events over the link to finish its part of the disconnection proceedure.
|
||||
|
||||
If the error response variant is received it should be logged if possible and treated as a success.
|
||||
|
||||
|
||||
### Example handshake
|
||||
|
||||
Server hello after opening the WebSocket.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "hello",
|
||||
"data": {
|
||||
"version": "2.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Host registering a new connection session.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "register_host",
|
||||
"data": {
|
||||
"token": "xyzabc123",
|
||||
"version": "2.0.0",
|
||||
"id": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Controller reply signaling the session is established and ready.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "ready",
|
||||
"data": {
|
||||
"session_token": "xyz.azy.bzy",
|
||||
"session_timeout": 60,
|
||||
"heartbeat_interval": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Host and controller can now send events and requests over the connection.
|
||||
@@ -1,30 +1,16 @@
|
||||
Save Patching
|
||||
-------------
|
||||
## Save Patching
|
||||
|
||||
In order to efficiently get data in and out of running Factorio servers
|
||||
and to provide custom interfaces Clusterio uses save
|
||||
patching<sup>[1]</sup> to add code to the game. The save patching is
|
||||
based on the built in
|
||||
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
|
||||
library to Factorio and requires scenarios to be coded specifically for
|
||||
it. Mods on the other hand work without modifications.
|
||||
In order to efficiently get data in and out of running Factorio servers and to provide custom interfaces Clusterio uses save patching<sup>[1]</sup> to add code to the game.
|
||||
The save patching is based on the built in [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) library to Factorio and requires scenarios to be coded specifically for it.
|
||||
Mods on the other hand work without modifications.
|
||||
|
||||
This can be disabled with the `factorio.enable_save_patching` config on
|
||||
a per instance basis, however disabling it means that modules are not
|
||||
loaded into the game and most plugins will not function.
|
||||
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
|
||||
|
||||
<sub>1: Before version 2.0 of Clusterio this was done at runtime
|
||||
with a patcher called Hotpatch, but due to the difficulty in supporting
|
||||
run time patching and the lack of documentation it was replace it with
|
||||
save patching instead.</sub>
|
||||
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
|
||||
|
||||
|
||||
User Accounts
|
||||
-------------
|
||||
## User Accounts
|
||||
|
||||
The master server creates and stores accounts for all Factorio users
|
||||
that log into instances under its control. These accounts are used to
|
||||
store per player data and authorize management of the cluster. By
|
||||
default users accounts are assigned a Player role that gives a limited
|
||||
read access to the cluster resources, and admins of the cluster can be
|
||||
assigned an Admin role to get access to manage it.
|
||||
The controller creates and stores accounts for all Factorio users that log into instances under its control.
|
||||
These accounts are used to store per player data and authorize management of the cluster.
|
||||
By default users accounts are assigned a Player role that gives a limited read access to the cluster resources, and admins of the cluster can be assigned a Cluster Admin role to get access to manage it.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"tags": {
|
||||
"allowUnknownTags": false
|
||||
},
|
||||
"opts": {
|
||||
"destination": "docs/api",
|
||||
"recurse": true
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"packages/master/src/",
|
||||
"packages/master/master.js",
|
||||
"packages/slave/slave.js",
|
||||
"packages/ctl/ctl.js",
|
||||
"packages/lib/",
|
||||
"plugins/"
|
||||
],
|
||||
"excludePattern": "node_modules"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# Managing a Cluster
|
||||
|
||||
Clusterio clusters are managed through the controller by using the `clusterioctl` command line interface which is invoked by running `npx clusterioctl <command>` in the clusterio directory.
|
||||
This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should be executed.
|
||||
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
|
||||
|
||||
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
|
||||
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
|
||||
|
||||
|
||||
## Hosts
|
||||
|
||||
### List hosts
|
||||
|
||||
ctl> host list
|
||||
|
||||
Lists all hosts.
|
||||
|
||||
### Generate token
|
||||
|
||||
ctl> host generate-token [--id <host id>]
|
||||
|
||||
Generate a token for a host. `--id` is the host id.
|
||||
|
||||
### Create config
|
||||
|
||||
ctl> host create-config [--id <host id>] [--name <host name>] [--generate-token <bool>] [--output <path to output>]
|
||||
|
||||
Create a host config. `--id` id the host id. `--name` is the host name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-host.json", "-" for stdout.
|
||||
|
||||
## Instances
|
||||
|
||||
### List Instances
|
||||
|
||||
ctl> instance list
|
||||
|
||||
List instances.
|
||||
|
||||
### Create instance
|
||||
|
||||
ctl> instance create <name> [--id <instance id>]
|
||||
|
||||
Create a new instance. `name` is the instance name. `--id` is the optional id.
|
||||
|
||||
### Kill instance
|
||||
|
||||
ctl> instance kill <name>
|
||||
|
||||
Terminates the running Factorio server without giving it a chance to save or cleanup after itself.
|
||||
|
||||
Note: This may cause loss of data.
|
||||
|
||||
### Extract players from instance
|
||||
|
||||
ctl> instance extract-players <name>
|
||||
|
||||
Creates a user account in the cluster for each player that has been online on the currently running save of the instance and sets the online time of that account on this instance to the online time recorded in the save.
|
||||
|
||||
Useful when importing a save to a cluster.
|
||||
Note that the online time in a save is recorded differntly from how Clusterio records online time.
|
||||
Most notably Clusterio records wall clock time while the save time is recorded in ticks and this extraction of player time assumes the game runs at 60 ticks a second.
|
||||
|
||||
|
||||
### Config management
|
||||
|
||||
#### List Configuration
|
||||
|
||||
ctl> instance config list <instance>
|
||||
|
||||
List config values for an instance. `instance` is the instance whose config values to list.
|
||||
|
||||
#### Set config value
|
||||
|
||||
ctl> instance config set <instance> <field> [value] [--stdin]
|
||||
|
||||
Set field in instance config. `instance` is instance to set. `field` is field to set. `value` is value to set in field. `--stdin` signals to read value from stdin.
|
||||
|
||||
#### Set config property of field
|
||||
|
||||
To be written.
|
||||
|
||||
#### Edit instance config
|
||||
|
||||
ctl> instance config edit <instance> [editor]
|
||||
|
||||
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
|
||||
|
||||
### Assign instance to host
|
||||
|
||||
ctl> instace assign <instance> [host]
|
||||
|
||||
Assigns instance to host. `instance` is instance to assign, `host` is host to assign to. If `host` is not provided, instance will be unassigned.
|
||||
|
||||
## Users
|
||||
|
||||
Clusterio automatically creates user accounts for all players that join an instance when save patching is enabled.
|
||||
These accounts are used to store per player data shared between instances, like if the player should be whitelisted, admin or banned.
|
||||
|
||||
### List users
|
||||
|
||||
ctl> user list
|
||||
|
||||
Lists all user accounts in the cluster along with some data for each.
|
||||
|
||||
|
||||
### Create User
|
||||
|
||||
ctl> user create <name>
|
||||
|
||||
Creates a new empty user account for the given Factorio player.
|
||||
Note that case matters here.
|
||||
This is usually not required as user accounts are created automatically for players that join instances with save patching enabled.
|
||||
|
||||
|
||||
### Promote User to Server Admin
|
||||
|
||||
ctl> user set-admin <name> [--revoke] [--create]
|
||||
|
||||
Promotes the user given to in-game admin on instances with the `sync_adminlist` option enabled.
|
||||
If the `--revoke` switch is used the user is removed from the adminlist.
|
||||
|
||||
Since admin status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
|
||||
|
||||
**Note:** Being a server admin does not grant any access to manage the cluster.
|
||||
See [set-roles](#set-cluster-roles) for adding roles which grant access to managing the clusetr.
|
||||
|
||||
|
||||
### Whitelist User
|
||||
|
||||
ctl> user set-whitelisted <name> [--remove] [--create]
|
||||
|
||||
Add the user given to the whitelist on instances with the `sync_whitelist` option enabled.
|
||||
If the `--remove` switch is used the user is removed from the whitelist.
|
||||
|
||||
Since whitelisted status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
|
||||
|
||||
|
||||
### Ban User
|
||||
|
||||
ctl> user set-banned <name> [--reason <message>] [--pardon] [--create]
|
||||
|
||||
Ban user in-game from instances with the `sync_banlist` option enabled.
|
||||
Reason is a message that will be shown to the user when attemption to log in.
|
||||
If the `--pardon` switch is used it removes the ban.
|
||||
|
||||
Since ban status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
|
||||
|
||||
Note: This bans the user from logging in to Factorio servers in the cluster, it does not revoke access to any cluster management they might have, see next section on setting roles for revoking tha.
|
||||
|
||||
### Bulk importing admin, whitelist and ban lists
|
||||
|
||||
Whitelists and admin lists can be bulk imported with the following commands:
|
||||
|
||||
ctl> user whitelist-bulk-import <file> [--create]
|
||||
ctl> user admin-bulk-import <file> [--create]
|
||||
|
||||
The file should simply be newline seperated names:
|
||||
|
||||
joe
|
||||
bob
|
||||
allan
|
||||
|
||||
The `--create` option will create the user if they do not exist, and then set the relevent value.
|
||||
|
||||
Banlists can be bulk imported with the following command:
|
||||
|
||||
ctl> user banned-bulk-import <file> [--create]
|
||||
|
||||
The file should be names, followed by a ban reason:
|
||||
|
||||
joe greifed copper mines
|
||||
bob stole materials
|
||||
allan rude language
|
||||
|
||||
The first string on the line is used as the username, the others are used as the ban reason.
|
||||
The `--create` option will create the user if they do not exist, then ban them.
|
||||
|
||||
### Set Cluster Roles
|
||||
|
||||
ctl> user set-roles <name> [roles...]
|
||||
|
||||
Replaces the roles the user has in the cluster with a new list of roles.
|
||||
Calling with an empty roles argument will remove all roles from the user.
|
||||
|
||||
By default there's a roled named Cluster Admin which grants access to everything and a role named Player which grants a limited read access to the cluster, see [the section on roles](#roles) for more information about setting up roles and permissions.
|
||||
|
||||
|
||||
### Delete user
|
||||
|
||||
ctl> user delete <name>
|
||||
|
||||
Deletes everything stored on the controller for this user.
|
||||
|
||||
Note: If the player is banned from the cluster this will effectively unban them, as the ban status is stored with the user account.
|
||||
|
||||
Note: If the player joins the cluster again a new account will be made for them automatically.
|
||||
|
||||
|
||||
## Roles
|
||||
|
||||
To be written.
|
||||
@@ -1,132 +0,0 @@
|
||||
Managing a Cluster
|
||||
==================
|
||||
|
||||
Clusterio clusers are managed through the master server by using the
|
||||
`clusterioctl` command line interface which is invoked by running
|
||||
`npx clusterioctl <command>` in the clusterio directory. This document
|
||||
uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should
|
||||
be executed. Mandatory parameters are shown in `<angles bracket>` and
|
||||
optional pameters are in `[square brackets]`.
|
||||
|
||||
Before `clusterioctl` can be used it needs to be configured for the
|
||||
cluster it will connect to. The easiest way to do this is to run
|
||||
`npx clusteriomaster bootstrap create-ctl-config <username>` on the
|
||||
master server, which creates the necessary `config-control.json` for
|
||||
managing the cluster as the given user.
|
||||
|
||||
|
||||
Slaves
|
||||
------
|
||||
|
||||
To be written.
|
||||
|
||||
|
||||
Instances
|
||||
---------
|
||||
|
||||
To be written.
|
||||
|
||||
|
||||
Users
|
||||
-----
|
||||
|
||||
Clusterio automatically creates user accounts for all players that join
|
||||
an instance when save patching is enabled. These accounts are used to
|
||||
store per player data shared between instances, like if the player
|
||||
should be whitelisted, admin or banned.
|
||||
|
||||
### List users
|
||||
|
||||
ctl> user list
|
||||
|
||||
Lists all user accounts in the cluster along with some data for each.
|
||||
|
||||
|
||||
### Create User
|
||||
|
||||
ctl> user create <name>
|
||||
|
||||
Creates a new empty user account for the given Factorio player. Note
|
||||
that case matters here. This is usually not required as user accounts
|
||||
are created automatically for players that join instances with save
|
||||
patching enabled.
|
||||
|
||||
|
||||
### Promote User to Server Admin
|
||||
|
||||
ctl> user set-admin <name> [--revoke] [--create]
|
||||
|
||||
Promotes the user given to in-game admin on instances with the
|
||||
`sync_adminlist` option enabled. If the `--revoke` switch is used the
|
||||
user is removed from the adminlist.
|
||||
|
||||
Since admin status is a part of the account data the account must exist
|
||||
for this to succeed, passing `--create` will create the account if it
|
||||
does not exist.
|
||||
|
||||
**Note:** Being a server admin does not grant any access to manage the
|
||||
cluster. See [set-roles](#set-cluster-roles) for adding roles which
|
||||
grant access to managing the clusetr.
|
||||
|
||||
|
||||
### Whitelist User
|
||||
|
||||
ctl> user set-whitelisted <name> [--remove] [--create]
|
||||
|
||||
Add the user given to the whitelist on instances with the
|
||||
`sync_whitelist` option enabled. If the `--remove` switch is used the
|
||||
user is removed from the whitelist.
|
||||
|
||||
Since whitelisted status is a part of the account data the account must
|
||||
exist for this to succeed, passing `--create` will create the account if
|
||||
it does not exist.
|
||||
|
||||
|
||||
### Ban User
|
||||
|
||||
ctl> user set-banned <name> [--reason <message>] [--pardon] [--create]
|
||||
|
||||
Ban user in-game from instances with the `sync_banlist` option enabled.
|
||||
Reason is a message that will be shown to the user when attemption to
|
||||
log in. If the `--pardon` switch is used it removes the ban.
|
||||
|
||||
Since ban status is a part of the account data the account must exist
|
||||
for this to succeed, passing `--create` will create the account if it
|
||||
does not exist.
|
||||
|
||||
Note: This bans the user from logging in to Factorio servers in the
|
||||
cluster, it does not revoke access to any cluster management they might
|
||||
have, see next section on setting roles for revoking tha.
|
||||
|
||||
|
||||
### Set Cluster Roles
|
||||
|
||||
ctl> user set-roles <name> [roles...]
|
||||
|
||||
Replaces the roles the user has in the cluster with a new list of roles.
|
||||
Calling with an empty roles argument will remove all roles from the
|
||||
user.
|
||||
|
||||
By default there's a roled named Admin which grants access to
|
||||
everything and a role named Player which grants a limited read access to
|
||||
the cluster, see [the section on roles](#roles) for more information
|
||||
about setting up roles and permissions.
|
||||
|
||||
|
||||
### Delete user
|
||||
|
||||
ctl> user delete <name>
|
||||
|
||||
Deletes everything stored on the master server for this user.
|
||||
|
||||
Note: If the player is banned from the cluster this will effectively
|
||||
unban them, as the ban status is stored with the user account.
|
||||
|
||||
Note: If the player joins the cluster again a new account will be made
|
||||
for them automatically.
|
||||
|
||||
|
||||
Roles
|
||||
-----
|
||||
|
||||
To be written.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Migrating from 2.0.0-alpha.13 to 2.0.0-alpha.14
|
||||
|
||||
Alpha 14 comes with significant breaking changes for existing clusters, most notably the rename of master and slave to controller and host.
|
||||
Please study the entire migration guide carefully before attempting to update an existing cluster.
|
||||
|
||||
## Before updating
|
||||
|
||||
If you're using mods you need to create mod pack string for the mod configurations you're currently running.
|
||||
You can do this by running the follow RCON command on an instance that is currently running the mod configuration you want to use (you can also run this command in single player and find the string printed in Factorio's [Log file](https://wiki.factorio.com/Log_file)):
|
||||
|
||||
/sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e)
|
||||
|
||||
This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later.
|
||||
Mod Pack strings looks something like this:
|
||||
|
||||
eNqFketuwyAMhd/Fv0OUVpW25lWmqSLESdAIRNh0l4h3n9mlitZt5QdCx/7so8MKXs8ILSzaPEEFPZKJdmEbvIgiDNpwiDaczhjpU93Vu/r+Tmpz6Anah/V7RqcJRUavO4c9tBwTVnAF5upCGJeIsYx3tvsPbQTdb0lKHYlnPJHY0+ONvcdjvW8gP1ZAyGz9KL5XINaR0yLPXEFMnu2ManSh0+6j/GOFitqXG4mjNSUidVm5wlm7JMYG7Qhl3BX8FjyqZ9vztOk+NM2fvRPaceLbzbN+UejQFFOWXzfArvk6v2HWD9ZLv5JfxA1UwsubPBaMKhHGklLO76vAwfQ=
|
||||
|
||||
If you have multiple mod configuration create a mod pack string for each and note them down.
|
||||
|
||||
Double check that you are on a supported version of Node.js by running `node --version`, the version should be v18 or later, if not you will need to update Node.js before continuing.
|
||||
|
||||
|
||||
## Migrate installations
|
||||
|
||||
**Note:** Check that plugins you are using have updates avaialble for alpha 14 before attempting to update, if they do not either wait for the plugin to be updated or uninstall the plugin from all places it is installed on first.
|
||||
|
||||
Shutdown all slaves and the master server and then migrate each installations by running the following command:
|
||||
|
||||
npm exec @clusterio/create -y -- --migrate-rename --log-level=verbose
|
||||
|
||||
This will convert the existing installation's usage of the master and slave names in packages, configs, database and logs to the new controller and host terms and update the installed packages.
|
||||
|
||||
If you're using systemd then copy the generated clusteriocontroller.service and/or clusteriohost.service files from the systemd folder to /etc/systemd/system/ and remove the old clusteriomaster.service and clusterioslave.service files.
|
||||
|
||||
Once the installations have been migrated start up the controller and hosts again.
|
||||
|
||||
|
||||
## Post installation
|
||||
|
||||
Import the Mod Pack strings that was created beforehand on the controller and upload the mods needed by those mod packs to the controller.
|
||||
You can assign a default mod pack in the controller config which instances use by default or set the mod pack on the per instance basis in the instance config.
|
||||
Once you have assigned a mod pack to an instance run a data export on the instance in order to properly populate the mod settings in the mod and get item icons and item names resolved in the web interface.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Setting up TLS
|
||||
|
||||
By default Clusterio will listen for connections over HTTP which is fine for local networks but over the Internet it should idealy be over HTTPS as there are some credentials that could be exposed to eavesdroppers.
|
||||
The recommended setup is to have an ordinary HTTPS server software like Apache or Nginx to act as a reverse proxy in front of the Clusterio controller.
|
||||
The reverse proxy would then be set up with a TLS certificate from a Certificate Authoritiy like [Let's Encrypt](https://letsencrypt.org).
|
||||
Alternatively you can have Clusterio listen for connections over HTTPS directly by providing it with a TLS certificate and private key.
|
||||
|
||||
Note that a TLS certificate signed by a recognized Certificate Authority in general requires that you own a domain.
|
||||
See the last section for how to create and use a self-signed certificate for an IP address if you don't have a domain.
|
||||
|
||||
|
||||
## Using a reverse proxy
|
||||
|
||||
Configure the proxy to forward requests over HTTP to the port the controller is listening to so that for example a request to `https://example.com/instances/` is forwarded to `http://localhost:8080/instances/`.
|
||||
It is possible to host the interface under a sub-path, in that case a request under the sub-path should be mapped to the controller with the sub-path stripped out.
|
||||
For example `https://example.com/sub/path/instances/` would be mapped to `http://localhost:8080/instances`.
|
||||
|
||||
Additianlly WebSocket connections to `/api/socket` also needs to be forwarded.
|
||||
Typically this requires explicit configuration and support for forwarding WebSocket connections.
|
||||
If the interface hosted under a sub-path the `/api/socket` address is relative to the sub-path and the sub-path also needs to be stripped out when forwarded.
|
||||
|
||||
You can also optionally set the controller to listen only on the loopback interface to prevent it being directly connected to from the outside.
|
||||
|
||||
npx clusteriocontroller config set controller.bind_address 127.0.0.1
|
||||
|
||||
|
||||
### Apache Config
|
||||
|
||||
To proxy with Apache make sure that `proxy_module`, `proxy_http_module` and `proxy_wstunnel_module` is loaded in the config, then add the following directives to wherever is the approriate place for your flavour of Apache config organization.
|
||||
|
||||
```apache
|
||||
<Location /sub/path/>
|
||||
ProxyPass "http://localhost:8080/"
|
||||
</location>
|
||||
<Location /sub/path/api/socket>
|
||||
ProxyPass "ws://localhost:8080/api/socket"
|
||||
</Location>
|
||||
```
|
||||
|
||||
Replace `/sub/path/` with the location on the proxy you want to host the Clusterio interface under.
|
||||
If the whole domain is to be dedicated to the Clusterio interface then `/sub/path/` should be just `/`.
|
||||
Pay attention to the use of trailing slashes, as they are important.
|
||||
|
||||
|
||||
## Hosting HTTPS directly from Node.js
|
||||
|
||||
Listening on HTTPS is supported by the Clusterio controller via the `controller.https_port` option provided paths to a certificate file and a private key file is configured for the `controller.tls_certificate` and `controller.tls_private_key` options.
|
||||
Both of these are expected to be unencrypted PEM files.
|
||||
|
||||
|
||||
## Creating and using a self-signed certificate
|
||||
|
||||
Create a self-signed certificate with OpenSSL by running
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3650 -subj /CN=localhost -addext "subjectAltName=IP:<ip>"
|
||||
|
||||
substituting `<ip>` with the IP address the server will be accessed under, for example `IP:203.0.113.4`.
|
||||
Multiple addresses can be given by supplying a comma sepparated list of `IP:address` pairs and DNS names can also be specified using the `DNS:example.com` format.
|
||||
You need to supply every name you want to connect to the server under the subjectAltName field, for example to connect to the HTTPS port locally via an address like `https://localhost:4443/` you need to add `DNS:localhost`.
|
||||
|
||||
This creates a private key file named `key.pem` and a public certificate file named `cert.pem` and these are used for the `controller.tls_certificate` and `controller.tls_private_key` config options respectively on the controller:
|
||||
|
||||
npx clusteriocontroller config set controller.tls_certificate cert.pem
|
||||
npx clusteriocontroller config set controller.tls_private_key key.pem
|
||||
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
|
||||
|
||||
npx clusteriohost config set host.tls_ca cert.pem
|
||||
|
||||
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
|
||||
|
||||
npx clusterioctl control-config set control.tls_ca cert.pem
|
||||
@@ -1,287 +0,0 @@
|
||||
WebSocket protocol
|
||||
==================
|
||||
|
||||
<sub>This document describes the protocol used between the master and
|
||||
slaves, and unless you're developing Clusterio it's probably not going
|
||||
to be very useful for you.</sub>
|
||||
|
||||
The protocol is built on WebSocket and uses JSON encoded text messages
|
||||
to communicate to and from the master server. These messages contain
|
||||
a JSON encoded object with the following three properties:
|
||||
|
||||
- seq - integer|null - The sequence number of the message
|
||||
- type - string - The type of message
|
||||
- data - object - Message data
|
||||
|
||||
For the handshake and heartbeats the seq property is null.
|
||||
|
||||
|
||||
Handshake
|
||||
---------
|
||||
|
||||
Upon connecting to the WebSocket endpoint the server will respond with a
|
||||
`hello` message containing the master server version as the data. The
|
||||
client is then expected to send either `register_*` or a `resume`
|
||||
message to set up the connection with the master. The exact flow is
|
||||
described below.
|
||||
|
||||
|
||||
### Client connection flow
|
||||
|
||||
For a client making a new connecting to the master server the first
|
||||
course of action after obtaining a token is to connect with WebSocket to
|
||||
the /api/socket endpoint and wait for the server hello. After the
|
||||
hello, the client sends a register\_\* message corresponding to the type
|
||||
of the client. If successful the server will reply with a ready message
|
||||
and the connection has been established. See the flowchart below.
|
||||
|
||||

|
||||
|
||||
If for any reason the connection is closed or lost during the connection
|
||||
attempt the process is started over after a random delay, with the
|
||||
exception of the connection being closed with a 4003 code which means
|
||||
authentication failed and the client should stop trying to connect. The
|
||||
wait for hello and wait for ready states should also have a timeout,
|
||||
after which the connection is closed if the hello/reply wasn't received
|
||||
in time.
|
||||
|
||||
|
||||
### Client reconnection flow
|
||||
|
||||
If the connection is lost after the client has received the ready
|
||||
message from the server it should attempt a reconnect instead. This is
|
||||
very similar to a new connection, but instead of a register\_\* messsage
|
||||
the client sends a resume message and the server replies with a continue
|
||||
message if resuming the session was successful. This continue message
|
||||
has sequence number of the last message the master received and the
|
||||
client is expected to imediately send all messages from its send buffer
|
||||
that occured after the sequence number given.
|
||||
|
||||
The server will respond with an invalidate message if resuming the
|
||||
session is not possible. In this case the client should continue from
|
||||
the new session state of the connection flow (see the previous section).
|
||||
|
||||

|
||||
|
||||
Like the connection flow the process starts over after a delay if the
|
||||
connection is closed or lost. The same exception for connection closed
|
||||
with 4003 applies as with the connection flow. Additionally after an
|
||||
invalidate message has been received and the connection flow fails, it
|
||||
should start over from the connection flow.
|
||||
|
||||
|
||||
### Server connection flow
|
||||
|
||||
From the master server side new connections and reconnection attempts
|
||||
start out the same, a new connection has been made and the server sends
|
||||
the hello messoge to the client. If a vaild register\_\* message is
|
||||
received a new connection is set up for the client and the ready message
|
||||
is sent in reply. If a valid resume message is received and the session
|
||||
that's attempted to be resumed is active it'll signal that the session
|
||||
is resumed with a continue message and the client upon receiving the
|
||||
continue message should send the messages from its send buffer from
|
||||
after the last\_seq given in the continue message.
|
||||
|
||||
If the session token used to resume is not valid it'll send an invalidate
|
||||
message and expect the client to continue with a registr\_\* message. If an
|
||||
invalid or unexpected message is received during the handshake the connection
|
||||
is closed. See the flowchart below.
|
||||
|
||||

|
||||
|
||||
For authentication failure the connection is closed with code 4003,
|
||||
which the connection client should interpret as connecting will never
|
||||
succeed and stop trying to reconnect.
|
||||
|
||||
|
||||
### Connection tracking and heartbeats
|
||||
|
||||
Once connected as indicated by the ready and continue messages from the
|
||||
master server the client must start sending heartbeats at the interval
|
||||
given in the ready/continue message, and storing messages sent over the
|
||||
link in a sending side buffer. The heartbeats sent must have the seq of
|
||||
the last received message on the link and acts as an ack for received
|
||||
messages. The master server also sends heartbeats in the same manner
|
||||
and the client should remove messages up to and including the sequence
|
||||
number received in the heartbeat from its send buffer.
|
||||
|
||||
|
||||
Handshake messages
|
||||
------------------
|
||||
|
||||
### `hello`
|
||||
|
||||
Sent by the master server when the WebSocket connection has been opened.
|
||||
|
||||
- version - string - The version of the master, e.g. "2.0.0".
|
||||
- plugins - Object<string, string> -
|
||||
Object mapping plugin names to plugin version for plugins that are
|
||||
loaded on the master server.
|
||||
|
||||
### `register_slave`
|
||||
|
||||
Slave handshake for establishing a new connection session.
|
||||
|
||||
- token - string - Authentication token.
|
||||
- agent - string - Human readable string of the software connecting.
|
||||
- version - string - The protocol version of the slave, e.g. "2.0.0".
|
||||
- name - string - Name of the slave.
|
||||
- id - integer - ID of the slave.
|
||||
- plugins - Object<string, string> -
|
||||
Object mapping plugin names to plugin version for plugins that are
|
||||
available on the slave.
|
||||
|
||||
### `register_control`
|
||||
|
||||
Control handshake for enstablishing a new connection session.
|
||||
|
||||
- token - string - Authentication token.
|
||||
- agent - string - Human readable string of the software connecting.
|
||||
- version - string - The protocol version of the control, e.g. "2.0.0".
|
||||
|
||||
### `resume`
|
||||
|
||||
Handshake for resuming an existing connection session where the
|
||||
connection had dropped unexpectedly.
|
||||
|
||||
- session_token - string - Session token to resume session with.
|
||||
- last_seq - integer|null - Last message seq received by the client.
|
||||
|
||||
### `invalidate`
|
||||
|
||||
Sent by the master server in reply to a resume for a session that cannot
|
||||
be resumed. The client should start a new session with a register\_\*
|
||||
message in response.
|
||||
|
||||
### `continue`
|
||||
|
||||
Sent by the master server to signal resuming the session was successful.
|
||||
|
||||
- last_seq - integer|null - Last message seq received by the server.
|
||||
- heartbeat_interval - number - Rate client should send heartbeats at.
|
||||
|
||||
### `ready`
|
||||
|
||||
Sent by the master server to signal the connection has been established
|
||||
with a new session.
|
||||
|
||||
- session_token - string - Token to resume the session with.
|
||||
- heartbeat_interval - number - Rate client should send heartbeats at.
|
||||
|
||||
### `heartbeat`
|
||||
|
||||
Sent by both the master server and clients at the interval given in the
|
||||
continue/ready messages.
|
||||
|
||||
- last_seq - integer - Last message seq received over the link.
|
||||
|
||||
|
||||
Example handshake
|
||||
-----------------
|
||||
|
||||
Server hello after opening the WebSocket.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "hello",
|
||||
"data": {
|
||||
"version": "2.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slave registering a new connection session.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "register_slave",
|
||||
"data": {
|
||||
"token": "xyzabc123",
|
||||
"agent": "Clusterio Slave",
|
||||
"version": "2.0.0",
|
||||
"name": "Foo",
|
||||
"id": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Master server reply signaling the session is established and ready.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "ready",
|
||||
"data": {
|
||||
"session_token": "xyz.azy.bzy",
|
||||
"heartbeat_interval": 45
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Events
|
||||
------
|
||||
|
||||
Events are messages send in one direction over the link, that the
|
||||
receiver is expected to act upon, but not make any replies (for that
|
||||
there's [Requests](#requests). By convention message types ending in
|
||||
`*_event` are events. The data payload of an event consists solely of
|
||||
event specific properties.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for
|
||||
the recognized events and their contents.
|
||||
|
||||
|
||||
Requests
|
||||
--------
|
||||
|
||||
Requests are messages which expects a response in return. They function
|
||||
a lot like HTTP requests except that both parties of a connection can
|
||||
inniate a request that is expected to be responded to. By convention
|
||||
message types ending in `*_request` are requests, and is expected to be
|
||||
replied to with a corresponding `*_response` message. If an error
|
||||
occured while processing the request an error response containing an
|
||||
error property in the data payload is sent instead.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for
|
||||
the recognized requests and their contents.
|
||||
|
||||
### `*_response`
|
||||
|
||||
- seq - integer - The seq number of the request message.
|
||||
- additional properties may be present depending on the type of request.
|
||||
|
||||
### `*_response` error
|
||||
|
||||
- seq - integer - The seq number of the request message.
|
||||
- error - string - Human readable text message describing the error.
|
||||
|
||||
|
||||
Graceful Disconnect
|
||||
-------------------
|
||||
|
||||
To ensure no data is lost when closing the link it's important to
|
||||
send a prepare disconnect request and wait for it to complete before
|
||||
closing the WebSocket. Either party of the link can send this request
|
||||
and the party sending the request should be the one that initates the
|
||||
close of the WebSocket.
|
||||
|
||||
### `prepare_disconnect_request`
|
||||
|
||||
Request graceful disconnect of the connection. The receiving party should
|
||||
stop tasks that send events and requests over the link and wait for
|
||||
pending requests it has over the link to complete, but it should not
|
||||
stop responding to requests sent to it. Only after this process is done
|
||||
should the response be sent.
|
||||
|
||||
### `prepare_disconnect_response`
|
||||
|
||||
Signals the preparation for disconnect is complete and that the
|
||||
connection can be closed. The party that initiatied the prepare
|
||||
disconnect request may still send other requests/events over the link to
|
||||
finish its part of the disconnection proceedure.
|
||||
|
||||
If the error response variant is received it should be logged if
|
||||
possible and treated as a success.
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
@@ -0,0 +1,4 @@
|
||||
# External plugins directory
|
||||
|
||||
Plugins placed here will have their npm dependencies incorporated in the shared pnpm storage for this repository when `pnpm install` is invoked.
|
||||
Use this to enable `--dev-plugin` to work with plugins developed outside of the Clusterio repository.
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"plugins/*"
|
||||
],
|
||||
"version": "2.0.0-alpha.0"
|
||||
}
|
||||
@@ -1,38 +1,47 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/*/test",
|
||||
"fast-test": "FAST_TEST=y npm test",
|
||||
"cover": "nyc npm test",
|
||||
"fast-cover": "FAST_TEST=y nyc npm test",
|
||||
"ci-cover": "nyc --reporter=lcovonly npm run-script test",
|
||||
"lint": "eslint \"*.js\" packages plugins test",
|
||||
"docs": "jsdoc --configure docs/jsdoc.json",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"nyc": {
|
||||
"exclude": [
|
||||
"test/**",
|
||||
"plugins/*/test/**"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16 <11 || >= 11.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/ctl": "file:./packages/ctl",
|
||||
"@clusterio/lib": "file:./packages/lib",
|
||||
"@clusterio/master": "file:packages/master",
|
||||
"@clusterio/slave": "file:./packages/slave",
|
||||
"eslint": "^7.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"jsdoc": "^3.6.3",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
"lerna": "^3.22.1",
|
||||
"mocha": "^6.2.0",
|
||||
"nyc": "^14.1.1",
|
||||
"phin": "^3.4.1"
|
||||
}
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
"cover": "nyc pnpm test",
|
||||
"fast-cover": "FAST_TEST=y nyc pnpm test",
|
||||
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"nyc": {
|
||||
"exclude": [
|
||||
"test/**",
|
||||
"plugins/*/test/**"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "^4.18.2",
|
||||
"form-data": "^4.0.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.0",
|
||||
"mocha": "^10.2.0",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.24.8",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
webpack.config.js
|
||||
dist/browser
|
||||
@@ -0,0 +1,114 @@
|
||||
# Clusterio Controller
|
||||
|
||||
Communication hub for Clusterio clusters.
|
||||
The controller forwards data between Clusterio hosts connected to it and allows the cluster to be remotely managed through WebSocket connections to it either by using the included web interface or the Clusterio ctl command line utility.
|
||||
|
||||
|
||||
# Usage
|
||||
|
||||
npx clusteriocontroller <command>
|
||||
|
||||
Common options:
|
||||
|
||||
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the controller.
|
||||
Defaults to `plugin-list.json` and will be created if it does not exist.
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for the controller.
|
||||
Defaults to `config-controller.json` and will be created if it does not exist.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
Configure plugins available to be loaded by the controller.
|
||||
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
|
||||
Remove a plugin by its name.
|
||||
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
|
||||
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
|
||||
|
||||
For example, uninstalling the Subspace Storage plugin:
|
||||
|
||||
npx clusteriocontroller plugin remove subspace_storage
|
||||
npm uninstall @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin list`
|
||||
|
||||
Lists the plugins set up to be available by name followed by path.
|
||||
|
||||
|
||||
### `config`
|
||||
|
||||
Manage the controller configuration offline.
|
||||
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
|
||||
|
||||
|
||||
#### `config set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
If value is not provided the entry is set to null.
|
||||
If the config entry is of type object the value must be a valid JSON serialization of an object.
|
||||
|
||||
See docs/configuration.md in the main repositiory for the available configuration entries.
|
||||
|
||||
|
||||
#### `config show <config-entry>`
|
||||
|
||||
Shows the value for a single config entry.
|
||||
|
||||
|
||||
#### `config list`
|
||||
|
||||
Lists up all configuration entries with their currently configured values.
|
||||
|
||||
|
||||
### `bootstrap`
|
||||
|
||||
Commands for setting up initial admin acount and access to the cluster.
|
||||
|
||||
|
||||
#### `bootstrap create-admin <name>`
|
||||
|
||||
Creates a new user account with the given name if it does not already exist in the cluster and grant it full cluster admin permission.
|
||||
You should be using your own Factorio multiplayer username here.
|
||||
|
||||
|
||||
#### `bootstrap generate-user-token <name>`
|
||||
|
||||
Generate an access token for logging in and possibly managing the cluster from the given user account.
|
||||
This can be used both in the web interface and as the `controller_token` to connect with clusterioctl.
|
||||
|
||||
|
||||
#### `bootstrap create-ctl-config <name>`
|
||||
|
||||
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
|
||||
|
||||
|
||||
### `run`
|
||||
|
||||
Runs the controller.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
|
||||
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Clusterio controller
|
||||
*
|
||||
* Facilitates communication between hosts and control of the cluster
|
||||
* through WebSocet connections, and hosts a webserver for browser
|
||||
* interfaces and Prometheus statistics export. It is remotely controlled
|
||||
* by {@link module:ctl/ctl}.
|
||||
*
|
||||
* @module controller/controller
|
||||
* @author Danielv123, Hornwitser
|
||||
* @example
|
||||
* npx clusteriocontroller run
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
|
||||
import crypto from "crypto";
|
||||
import setBlocking from "set-blocking";
|
||||
import yargs from "yargs";
|
||||
import util from "util";
|
||||
import winston from "winston";
|
||||
import DailyRotateFile from "winston-daily-rotate-file";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
// homebrew modules
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { ConsoleTransport, levels, logger } = lib;
|
||||
|
||||
import Controller from "./src/Controller";
|
||||
import UserManager from "./src/UserManager";
|
||||
import { version } from "./package.json";
|
||||
|
||||
// globals
|
||||
let controller: Controller;
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_pending_requests",
|
||||
"Count of pending link requests currently waiting in memory on the controller.",
|
||||
{
|
||||
callback: (gauge) => {
|
||||
let count = 0;
|
||||
if (controller) {
|
||||
for (const host of controller.wsServer.hostConnections.values()) {
|
||||
count += host.pendingRequestCount;
|
||||
}
|
||||
for (const control of controller.wsServer.controlConnections.values()) {
|
||||
count += control.pendingRequestCount;
|
||||
}
|
||||
}
|
||||
gauge.set(count);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_host_mapping",
|
||||
"Mapping of Host ID to name",
|
||||
{
|
||||
labels: ["host_id", "host_name"],
|
||||
callback: function(gauge:lib.Gauge) {
|
||||
gauge.clear();
|
||||
if (!controller || !controller.hosts) {
|
||||
return;
|
||||
}
|
||||
for (let [id, host] of controller.hosts) {
|
||||
gauge.labels({
|
||||
host_id: String(id),
|
||||
host_name: host.name,
|
||||
}).set(1);
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_instance_mapping",
|
||||
"Mapping of Instance ID to name and host",
|
||||
{
|
||||
labels: ["instance_id", "instance_name", "host_id"],
|
||||
callback: function(gauge:lib.Gauge) {
|
||||
gauge.clear();
|
||||
if (!controller || !controller.instances) {
|
||||
return;
|
||||
}
|
||||
for (let [id, instance] of controller.instances) {
|
||||
gauge.labels({
|
||||
instance_id: String(id),
|
||||
instance_name: String(instance.config.get("instance.name")),
|
||||
host_id: String(instance.config.get("instance.assigned_host")),
|
||||
}).set(1);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_websocket_active_connections",
|
||||
"How many WebSocket connections are currently open to the controller",
|
||||
{ callback: function(gauge:lib.Gauge) {
|
||||
gauge.set(controller.wsServer.activeConnectors.size);
|
||||
}}
|
||||
);
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_active_hosts",
|
||||
"How many hosts are currently connected to the controller",
|
||||
{ callback: function(gauge:lib.Gauge) {
|
||||
gauge.set(controller.wsServer.hostConnections.size);
|
||||
}}
|
||||
);
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_connected_clients_count",
|
||||
"How many clients are currently connected to this controller",
|
||||
{
|
||||
labels: ["type"], callback: async function(gauge:lib.Gauge) {
|
||||
gauge.labels("host").set(controller.wsServer.hostConnections.size);
|
||||
gauge.labels("control").set(controller.wsServer.controlConnections.size);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
async function handleBootstrapCommand(
|
||||
args: any,
|
||||
controllerConfig: lib.ControllerConfig
|
||||
): Promise<void> {
|
||||
let subCommand = args._[1];
|
||||
let userManager = new UserManager(controllerConfig);
|
||||
await userManager.load(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
if (subCommand === "create-admin") {
|
||||
if (!args.name) {
|
||||
logger.error("name cannot be blank");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let admin = userManager.users.get(args.name);
|
||||
if (!admin) {
|
||||
admin = userManager.createUser(args.name);
|
||||
}
|
||||
|
||||
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
|
||||
admin.roleIds.add(adminRole.id);
|
||||
admin.isAdmin = true;
|
||||
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
|
||||
} else if (subCommand === "generate-user-token") {
|
||||
let user = userManager.users.get(args.name);
|
||||
if (!user) {
|
||||
logger.error(`No user named '${args.name}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(userManager.signUserToken(user.name));
|
||||
|
||||
} else if (subCommand === "generate-host-token") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
let admin = userManager.users.get(args.name);
|
||||
if (!admin) {
|
||||
logger.error(`No user named '${args.name}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let controlConfig = new lib.ControlConfig("control");
|
||||
|
||||
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
controlConfig.set(
|
||||
"control.controller_token",
|
||||
userManager.signUserToken(admin.name),
|
||||
);
|
||||
|
||||
let content = JSON.stringify(controlConfig, null, "\t");
|
||||
if (args.output === "-") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await lib.safeOutputFile(args.output, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export interface ControllerArgs {
|
||||
[x: string]: unknown;
|
||||
logLevel: string;
|
||||
logDirectory: string;
|
||||
pluginList: string;
|
||||
devPlugin?: (string | number)[] | undefined;
|
||||
dev?: boolean;
|
||||
config: string;
|
||||
_: (string | number)[];
|
||||
$0: string;
|
||||
}
|
||||
|
||||
interface InitializeParameters {
|
||||
args: ControllerArgs;
|
||||
shouldRun: boolean;
|
||||
clusterLogger: winston.Logger;
|
||||
pluginInfos: lib.PluginNodeEnvInfo[] | null;
|
||||
controllerConfigPath: string | null;
|
||||
controllerConfig: lib.ControllerConfig | null;
|
||||
}
|
||||
|
||||
async function initialize(): Promise<InitializeParameters> {
|
||||
// argument parsing
|
||||
const args = await yargs
|
||||
.scriptName("controller")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stdout",
|
||||
default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)),
|
||||
type: "string",
|
||||
})
|
||||
.option("log-directory", {
|
||||
nargs: 1,
|
||||
describe: "Directory to place logs in",
|
||||
default: "logs",
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "controller config file to use",
|
||||
default: "config-controller.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("config", "Manage Controller config", lib.configCommand)
|
||||
.command("bootstrap", "Bootstrap access to cluster", yargs => {
|
||||
yargs
|
||||
.command("create-admin <name>", "Create a cluster admin")
|
||||
.command("generate-user-token <name>", "Generate authentication token for the given user")
|
||||
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
|
||||
yargs.positional("id", { describe: "ID of the host", type: "number" });
|
||||
})
|
||||
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
|
||||
yargs.option("output", {
|
||||
describe: "Path to output config (- for stdout)", type: "string",
|
||||
nargs: 1, default: "config-control.json",
|
||||
});
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run");
|
||||
})
|
||||
.command("run", "Run controller", yargs => {
|
||||
yargs.option("can-restart", {
|
||||
type: "boolean", nargs: 0, default: false,
|
||||
describe: "Indicate that a process monitor will restart the controller on failure",
|
||||
});
|
||||
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
|
||||
yargs.option("dev-plugin", { hidden: true, type: "array" });
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.argv
|
||||
;
|
||||
|
||||
// Combined log stream of the whole cluster.
|
||||
const clusterLogger = winston.createLogger({
|
||||
format: winston.format.json(),
|
||||
level: "verbose",
|
||||
levels,
|
||||
});
|
||||
clusterLogger.add(new DailyRotateFile({
|
||||
format: winston.format.json(),
|
||||
filename: "cluster-%DATE%.log",
|
||||
utc: true,
|
||||
dirname: path.join(args.logDirectory, "cluster"),
|
||||
}));
|
||||
|
||||
// Log stream for the controller.
|
||||
logger.add(new DailyRotateFile({
|
||||
format: winston.format.json(),
|
||||
filename: "controller-%DATE%.log",
|
||||
utc: true,
|
||||
dirname: path.join(args.logDirectory, "controller"),
|
||||
}));
|
||||
logger.add(new winston.transports.Stream({
|
||||
stream: clusterLogger,
|
||||
}));
|
||||
if (args.logLevel !== "none") {
|
||||
logger.add(new ConsoleTransport({
|
||||
level: args.logLevel,
|
||||
format: new lib.TerminalFormat(),
|
||||
}));
|
||||
}
|
||||
lib.handleUnhandledErrors();
|
||||
|
||||
let command = args._[0];
|
||||
let shouldRun = false;
|
||||
if (command === "run") {
|
||||
logger.info(`Starting Clusterio controller ${version}`);
|
||||
shouldRun = true;
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, { encoding: "utf8" })));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (command === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return {
|
||||
args,
|
||||
shouldRun,
|
||||
clusterLogger,
|
||||
pluginInfos: null,
|
||||
controllerConfigPath: null,
|
||||
controllerConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
|
||||
const controllerConfigPath = args.config;
|
||||
logger.info(`Loading config from ${controllerConfigPath}`);
|
||||
let controllerConfig = new lib.ControllerConfig("controller");
|
||||
try {
|
||||
let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" }));
|
||||
controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller");
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info("Config not found, initializing new config");
|
||||
controllerConfig = new lib.ControllerConfig("controller");
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!controllerConfig.get("controller.auth_secret")) {
|
||||
logger.info("Generating new controller authentication secret");
|
||||
let asyncRandomBytes = util.promisify(crypto.randomBytes);
|
||||
let bytes = await asyncRandomBytes(256);
|
||||
controllerConfig.set("controller.auth_secret", bytes.toString("base64"));
|
||||
await lib.safeOutputFile(
|
||||
controllerConfigPath, JSON.stringify(controllerConfig, null, "\t")
|
||||
);
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
await lib.handleConfigCommand(
|
||||
args, controllerConfig, controllerConfigPath
|
||||
);
|
||||
|
||||
} else if (command === "bootstrap") {
|
||||
await handleBootstrapCommand(args, controllerConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
args,
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
controllerConfigPath,
|
||||
controllerConfig,
|
||||
shouldRun,
|
||||
};
|
||||
}
|
||||
|
||||
async function startup() {
|
||||
// Set the process title, shows up as the title of the CMD window on windows
|
||||
// and as the process name in ps/top on linux.
|
||||
process.title = "clusteriocontroller";
|
||||
|
||||
let {
|
||||
args,
|
||||
shouldRun,
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
controllerConfigPath,
|
||||
controllerConfig,
|
||||
} = await initialize();
|
||||
if (!shouldRun || !pluginInfos || !controllerConfigPath || !controllerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller = new Controller(
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
controllerConfigPath,
|
||||
controllerConfig,
|
||||
Boolean(args.canRestart),
|
||||
...await Controller.bootstrap(controllerConfig)
|
||||
);
|
||||
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
await controller.start(args);
|
||||
}
|
||||
|
||||
export function bootstrap() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startup().catch(err => {
|
||||
if (err instanceof lib.StartupError) {
|
||||
logger.fatal(`
|
||||
+-------------------------------+
|
||||
| Unable to to start controller |
|
||||
+-------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else if (err instanceof lib.PluginError) {
|
||||
logger.fatal(`
|
||||
${err.pluginName} plugin threw an unexpected error
|
||||
during startup, please report it to the plugin author.
|
||||
------------------------------------------------------
|
||||
${err.original.stack}`
|
||||
);
|
||||
} else {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured while starting controller, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { bootstrap } from "./controller";
|
||||
export { default as Controller } from "./src/Controller";
|
||||
export { default as ControllerUser } from "./src/ControllerUser";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter";
|
||||
export { default as ControlConnection } from "./src/ControlConnection";
|
||||
export { default as HostConnection } from "./src/HostConnection";
|
||||
export { default as InstanceInfo } from "./src/InstanceInfo";
|
||||
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
|
||||
export { default as UserManager } from "./src/UserManager";
|
||||
export { default as WsServer } from "./src/WsServer";
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@clusterio/controller",
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.18.2",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"winston": "^3.10.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"ws": "^8.13.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.4.14",
|
||||
"typescript": "^5.1.6",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-dev-middleware": "^6.1.1",
|
||||
"webpack-merge": "^5.9.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import * as routes from "./routes";
|
||||
|
||||
|
||||
/**
|
||||
* Base class for controller connections
|
||||
*
|
||||
* @extends module:lib.Link
|
||||
* @alias module:controller/src/BaseConnection
|
||||
*/
|
||||
export default class BaseConnection extends lib.Link {
|
||||
declare ["connector"]: WsServerConnector;
|
||||
|
||||
constructor(
|
||||
connector: lib.WebSocketBaseConnector,
|
||||
public _controller: Controller
|
||||
) {
|
||||
super(connector);
|
||||
this.router = _controller.router;
|
||||
for (let [Request, handler] of this._controller._registeredRequests) { this.handle(Request, handler); }
|
||||
for (let [Request, handler] of this._controller._fallbackedRequests) { this.fallbackRequest(Request, handler); }
|
||||
for (let [Event, handler] of this._controller._registeredEvents) { this.handle(Event, handler); }
|
||||
for (let [Event, handler] of this._controller._snoopedEvents) { this.snoopEvent(Event, handler); }
|
||||
|
||||
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
|
||||
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
|
||||
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
this._controller.subscriptions.unsubscribe(this);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(code: number, reason: string) {
|
||||
try {
|
||||
await this.connector.disconnect();
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof lib.SessionLost)) {
|
||||
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the link is connected, not in the dropped state and not in
|
||||
* the process of disconnecting.
|
||||
*/
|
||||
get connected(): boolean {
|
||||
return this.connector.connected;
|
||||
}
|
||||
|
||||
async handleModPackGetRequest(request: lib.ModPackGetRequest): Promise<lib.ModPack> {
|
||||
let { id } = request;
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
return modPack;
|
||||
}
|
||||
|
||||
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
|
||||
let id = this._controller.config.get("controller.default_mod_pack_id");
|
||||
if (id === null) {
|
||||
throw new lib.RequestError("Default mod pack not set on controller");
|
||||
}
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Default mod pack configured (${id}) does not exist`);
|
||||
}
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: string, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Mod ${filename} does not exist on controller`);
|
||||
}
|
||||
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
|
||||
throw new lib.RequestError(`Mod ${filename} checksum does not match controller's checksum`);
|
||||
}
|
||||
return modInfo;
|
||||
}
|
||||
|
||||
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
|
||||
let mod = this.getMod(request);
|
||||
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
|
||||
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = mod.filename;
|
||||
stream.source = fs.createReadStream(modPath);
|
||||
stream.mime = "application/zip";
|
||||
stream.size = String(mod.size);
|
||||
|
||||
return stream.id;
|
||||
}
|
||||
|
||||
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
|
||||
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
CollectorResult, Event, InstanceStatus, Logger,
|
||||
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
|
||||
} from "@clusterio/lib";
|
||||
import type Controller from "./Controller";
|
||||
import type InstanceInfo from "./InstanceInfo";
|
||||
import type ControlConnection from "./ControlConnection";
|
||||
import type HostConnection from "./HostConnection";
|
||||
|
||||
/**
|
||||
* Base class for controller plugins
|
||||
*
|
||||
* Controller plugins are subclasses of this class which get instantiated by
|
||||
* the controller on startup when the plugin is enabled in the config.
|
||||
* To be discovered the class must be exported under the name `ControllerPlugin`
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's
|
||||
* `plugin` export.
|
||||
*/
|
||||
export default class BaseControllerPlugin {
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
constructor(
|
||||
public info: PluginNodeEnvInfo,
|
||||
public controller: Controller,
|
||||
public metrics: any,
|
||||
logger: Logger
|
||||
) {
|
||||
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called when the controller saves data in memory to disk
|
||||
*
|
||||
* Invoked on the configured controller.autosave_interval by the
|
||||
* controller and intended to be used to flush any in memory data that
|
||||
* has changed to disk.
|
||||
*
|
||||
* This will also be called during graceful shutdown after {@link
|
||||
* BaseControllerPlugin.onShutdown} have been invoked and all links have
|
||||
* been disconnected.
|
||||
*/
|
||||
async onSaveData() { }
|
||||
|
||||
/**
|
||||
* Called when the status of an instance changes
|
||||
*
|
||||
* Invoked when the controller has changed the status of an instance
|
||||
* or received notice from a host that the status of an instance has
|
||||
* changed. The possible statuses that can be notified about are:
|
||||
* - `unassigned:`: Instance is no longer asssigned to a host.
|
||||
* - `unknown`: Host assigned to instance is offline.
|
||||
* - `stopped`: Instance is no longer running or was just assigned to a
|
||||
* host.
|
||||
* - `starting`: Instance is in the process of starting up.
|
||||
* - `running`: Instance startup completed and is now running.
|
||||
* - `stopping`: Instance is in the processing stopping.
|
||||
* - `creating_save`: Instance is in the process of creating a save.
|
||||
* - `exporting_data`: Instance is in the process of exporting item
|
||||
* icons and locale data.
|
||||
* - `deleted`: Instance was deleted.
|
||||
*
|
||||
* On controller startup all known instances gets the status `unknown` if
|
||||
* they are assigned to a host, when the host then connects to the
|
||||
* controller and informs of the current status of its instances this hook
|
||||
* is invoked for all those instances. You can detect this situation by
|
||||
* checking if prev equals `unknown`. If the host disconnects the
|
||||
* instances will again get the `unknown` status.
|
||||
*
|
||||
* When instances are created on the controller they will notify of a status
|
||||
* change with prev set to null. While the status of new instances is
|
||||
* in most cases `unassigned` it's possible for the created instance to
|
||||
* start with any state in some host connection corner cases.
|
||||
*
|
||||
* Note that it's possible for status change notification to get lost in
|
||||
* the case of network outages if reconnect fails to re-establish the
|
||||
* session between the controller and the host.
|
||||
*
|
||||
* @param instance -
|
||||
* The instance that changed.
|
||||
* @param prev - the previous status of the instance.
|
||||
*/
|
||||
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
|
||||
|
||||
/**
|
||||
* Called when the value of a controller config field changed.
|
||||
*
|
||||
* Invoked after the value of the config field given by `field` has
|
||||
* changed on the controller.
|
||||
*
|
||||
* @param field - Name of the field that changed.
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
|
||||
|
||||
/**
|
||||
* Called when the value of an instance config field changed.
|
||||
*
|
||||
* Invoked after the value of the config field given by `field` has
|
||||
* changed on an instance.
|
||||
*
|
||||
* @param instance -
|
||||
* The instance the config changed on.
|
||||
* @param field - Name of the field that changed.
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
|
||||
|
||||
/**
|
||||
* Called before collecting Prometheus metrics
|
||||
*
|
||||
* Invoked before the default metrics of prometheus is collected. Note
|
||||
* that since this is done while replying to /metrics this should not
|
||||
* take a long time, otherwise the requst will time out.
|
||||
*
|
||||
* Although you can return the results of collecting your prometheus
|
||||
* collectors directly, all gauges in the default registry is already
|
||||
* automatically collected. It's therefore recommended to only update
|
||||
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
|
||||
* collecting the values.
|
||||
*
|
||||
* @returns an async iterator of prometheus metric results or undefined.
|
||||
*/
|
||||
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
|
||||
|
||||
/**
|
||||
* Called when the controller is shutting down
|
||||
*/
|
||||
async onShutdown() { }
|
||||
|
||||
/**
|
||||
* Called when an event on a host connection happens
|
||||
*
|
||||
* The event param may be one of connect, drop, resume and close and has
|
||||
* the following meaning:
|
||||
*
|
||||
* ##### connect
|
||||
*
|
||||
* Invoked when a new connection to the host has been established.
|
||||
*
|
||||
* ##### drop
|
||||
*
|
||||
* Invoked when a connection loss is detected between the controller and a
|
||||
* host. Plugins should respond to this event by throtteling messages
|
||||
* it is sending to the given host connection to an absolute minimum.
|
||||
*
|
||||
* Messages sent over a dropped host connection will get queued up in
|
||||
* memory on the controller and sent all in one go when the connection is
|
||||
* re-established again.
|
||||
*
|
||||
* ##### resume
|
||||
*
|
||||
* Invoked when a connection that had previously been dropped is
|
||||
* re-established
|
||||
*
|
||||
* ##### close
|
||||
*
|
||||
* Invoked when the connection to the host has been closed.
|
||||
*
|
||||
* @param connection -
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume and close
|
||||
*/
|
||||
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
|
||||
|
||||
/**
|
||||
* Called when an avent on a control connection happens
|
||||
*
|
||||
* The event param may be one of connect, drop, resume and close and has
|
||||
* the following meaning:
|
||||
*
|
||||
* ##### connect
|
||||
*
|
||||
* Invoked when a new connection to the control has been established.
|
||||
*
|
||||
* ##### drop
|
||||
*
|
||||
* Invoked when a connection loss is detected between the controller and a
|
||||
* control. Plugins should respond to this event by throtteling
|
||||
* messages it is sending to the given control connection to an absolute
|
||||
* minimum.
|
||||
*
|
||||
* Messages sent over a dropped control connection will get queued up in
|
||||
* memory on the controller and sent all in one go when the connection is
|
||||
* re-established again.
|
||||
*
|
||||
* ##### resume
|
||||
*
|
||||
* Invoked when a connection that had previously been dropped is
|
||||
* re-established.
|
||||
*
|
||||
* ##### close
|
||||
*
|
||||
* Invoked when the connection to the control has been closed.
|
||||
*
|
||||
* @param connection -
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume, and close.
|
||||
*/
|
||||
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
|
||||
|
||||
/**
|
||||
* Called when a host is preparing to disconnect from the controller
|
||||
*
|
||||
* Invoked when a host has requested the disconnection from the controller.
|
||||
* This typically happens when it is in the process of shutting down.
|
||||
*
|
||||
* Plugins must stop sending messages to the host in question after the
|
||||
* prepare disconnect has been handled.
|
||||
*
|
||||
* @param connection -
|
||||
* The connection to the host preparing to disconnect.
|
||||
*/
|
||||
async onPrepareHostDisconnect(connection: HostConnection) { }
|
||||
|
||||
/**
|
||||
* Called when one or more mod packs are updated
|
||||
*
|
||||
* Invoked when one or more mod pack have been added, updated or deleted
|
||||
* in the cluster.
|
||||
*
|
||||
* If a mod pack has been deleted its `.isDeleted` property will be
|
||||
* true.
|
||||
*
|
||||
* @param modPacks - Mod packs that updated.
|
||||
*/
|
||||
async onModPacksUpdated(modPacks: ModPack[]) { }
|
||||
|
||||
/**
|
||||
* Called when one or more mod stored on the controller are updated
|
||||
*
|
||||
* Invoked when one or more mods have been added, updated or deleted
|
||||
* from the pool of shared mods stored on the cluster.
|
||||
*
|
||||
* If a mod has been deleted its `.isDeleted` property will be true.
|
||||
*
|
||||
* @param mods - Mods that updated.
|
||||
*/
|
||||
async onModsUpdated(mods: ModInfo[]) { }
|
||||
|
||||
/**
|
||||
* Called when a player joins or leaves an instance
|
||||
*
|
||||
* Invoked when a player either joins or leaves an instance in the
|
||||
* cluster.
|
||||
*
|
||||
* @param instance -
|
||||
* The instance it occured on.
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
|
||||
|
||||
/**
|
||||
* Broadcast event to all connected hosts
|
||||
*
|
||||
* Sends the given event to all hosts connected to the controller.
|
||||
* This does not include hosts that are in the process of closing the
|
||||
* connection, which typically happens when they are shutting down.
|
||||
*
|
||||
* @param event - Event to send
|
||||
*/
|
||||
broadcastEventToHosts<T>(event: Event<T>) {
|
||||
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
|
||||
if (
|
||||
!hostConnection.connector.closing
|
||||
&& (!event.constructor.plugin || hostConnection.plugins.has(event.constructor.plugin))
|
||||
) {
|
||||
hostConnection.send(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import events from "events";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logFilter, logger } = lib;
|
||||
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import * as routes from "./routes";
|
||||
import Controller from "./Controller";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
const queryLogTime = new lib.Summary(
|
||||
"clusterio_controller_query_log_duration_seconds",
|
||||
"Time in seconds log queries took to execute."
|
||||
);
|
||||
|
||||
/**
|
||||
* Represents the connection to a control link
|
||||
*
|
||||
* @extends module:controller/src/BaseConnection
|
||||
* @alias module:controller/src/ControlConnection
|
||||
*/
|
||||
export default class ControlConnection extends BaseConnection {
|
||||
private _version: string;
|
||||
logTransport: lib.LinkTransport | null = null;
|
||||
logSubscriptions = {
|
||||
all: false,
|
||||
controller: false,
|
||||
hostIds: [] as number[],
|
||||
instanceIds: [] as number[],
|
||||
};
|
||||
|
||||
ws_dumper: ((...args: any[]) => void) | null = null;
|
||||
declare connector: WsServerConnector;
|
||||
|
||||
constructor(
|
||||
registerData: { version: string },
|
||||
connector: WsServerConnector,
|
||||
controller: Controller,
|
||||
public user: ControllerUser, // The user making this connection.
|
||||
public id: number
|
||||
) {
|
||||
super(connector, controller);
|
||||
|
||||
this._version = registerData.version;
|
||||
|
||||
this.connector.on("connect", () => {
|
||||
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
|
||||
});
|
||||
this.connector.on("close", () => {
|
||||
if (this.logTransport) {
|
||||
logger.remove(this.logTransport);
|
||||
this.logTransport = null;
|
||||
}
|
||||
if (this.ws_dumper) {
|
||||
this._controller.debugEvents.off("message", this.ws_dumper);
|
||||
}
|
||||
});
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
this.connector.on(event, () => {
|
||||
for (let controllerPlugin of this._controller.plugins.values()) {
|
||||
controllerPlugin.onControlConnectionEvent(this, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
|
||||
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
this.handle(lib.HostListRequest, this.handleHostListRequest.bind(this));
|
||||
this.handle(lib.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this));
|
||||
this.handle(lib.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this));
|
||||
this.handle(lib.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this));
|
||||
this.handle(lib.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
|
||||
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceRenameSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
|
||||
this.handle(lib.InstanceCopySaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
|
||||
this.handle(lib.InstanceDeleteSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
|
||||
this.handle(lib.InstanceDownloadSaveRequest, this.handleInstanceDownloadSaveRequest.bind(this));
|
||||
this.handle(lib.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this));
|
||||
this.handle(lib.ModPackListRequest, this.handleModPackListRequest.bind(this));
|
||||
this.handle(lib.ModPackCreateRequest, this.handleModPackCreateRequest.bind(this));
|
||||
this.handle(lib.ModPackUpdateRequest, this.handleModPackUpdateRequest.bind(this));
|
||||
this.handle(lib.ModPackDeleteRequest, this.handleModPackDeleteRequest.bind(this));
|
||||
this.handle(lib.ModGetRequest, this.handleModGetRequest.bind(this));
|
||||
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
|
||||
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
|
||||
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
|
||||
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
|
||||
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
|
||||
this.handle(lib.PermissionListRequest, this.handlePermissionListRequest.bind(this));
|
||||
this.handle(lib.RoleListRequest, this.handleRoleListRequest.bind(this));
|
||||
this.handle(lib.RoleCreateRequest, this.handleRoleCreateRequest.bind(this));
|
||||
this.handle(lib.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this));
|
||||
this.handle(lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this));
|
||||
this.handle(lib.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this));
|
||||
this.handle(lib.UserGetRequest, this.handleUserGetRequest.bind(this));
|
||||
this.handle(lib.UserListRequest, this.handleUserListRequest.bind(this));
|
||||
this.handle(lib.UserCreateRequest, this.handleUserCreateRequest.bind(this));
|
||||
this.handle(lib.UserRevokeTokenRequest, this.handleUserRevokeTokenRequest.bind(this));
|
||||
this.handle(lib.UserUpdateRolesRequest, this.handleUserUpdateRolesRequest.bind(this));
|
||||
this.handle(lib.UserSetAdminRequest, this.handleUserSetAdminRequest.bind(this));
|
||||
this.handle(lib.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this));
|
||||
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
|
||||
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
let origin = this.connector.dst;
|
||||
if (origin.type !== message.src.type || origin.id !== message.src.id) {
|
||||
throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`);
|
||||
}
|
||||
}
|
||||
|
||||
validatePermission(
|
||||
message: lib.MessageRequest | lib.MessageEvent,
|
||||
entry: lib.RequestEntry | lib.EventEntry
|
||||
) {
|
||||
try {
|
||||
this.checkPermission(message, entry);
|
||||
} catch (err: any) {
|
||||
this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src);
|
||||
logger.audit(`Permission denied for ${message.name} by ${this.user.name} from ${this.connector.dst}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
checkPermission(
|
||||
message: lib.MessageRequest | lib.MessageEvent,
|
||||
entry: lib.RequestEntry | lib.EventEntry
|
||||
) {
|
||||
let permission;
|
||||
if (message.type === "request") {
|
||||
permission = (entry as lib.RequestEntry).Request.permission;
|
||||
} else if (message.type === "event") {
|
||||
permission = (entry as lib.EventEntry).Event.permission;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof permission === "string") {
|
||||
this.user.checkPermission(permission);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof permission === "function") {
|
||||
permission(this.user, message);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Should be unreachable");
|
||||
}
|
||||
|
||||
async handleControllerStopRequest() {
|
||||
this._controller.stop();
|
||||
}
|
||||
|
||||
async handleControllerRestartRequest() {
|
||||
if (!this._controller.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
}
|
||||
this._controller.shouldRestart = true;
|
||||
this._controller.stop();
|
||||
}
|
||||
|
||||
async handleControllerConfigGetRequest() {
|
||||
return this._controller.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
|
||||
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
|
||||
}
|
||||
|
||||
async handleControllerConfigSetPropRequest(request: lib.ControllerConfigSetPropRequest) {
|
||||
let { field, prop, value } = request;
|
||||
this._controller.config.setProp(field as keyof lib.ControllerConfigFields, prop, value, "control");
|
||||
}
|
||||
|
||||
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
|
||||
const host = this._controller.hosts.get(request.hostId);
|
||||
if (!host) {
|
||||
throw new Error(`Unknown host id (${request.hostId})`);
|
||||
}
|
||||
|
||||
host.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
|
||||
const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId);
|
||||
if (hostConnection) {
|
||||
hostConnection.connector.terminate();
|
||||
}
|
||||
|
||||
this._controller.hostsUpdated([host]);
|
||||
}
|
||||
|
||||
async handleHostListRequest(): Promise<lib.HostDetails[]> {
|
||||
return [...this._controller.hosts.values()].map(host => host.toHostDetails());
|
||||
}
|
||||
|
||||
async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) {
|
||||
let hostId = message.hostId;
|
||||
if (hostId === undefined) {
|
||||
hostId = Math.random() * 2**31 | 0;
|
||||
}
|
||||
return this._controller.generateHostToken(hostId);
|
||||
}
|
||||
|
||||
async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) {
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
|
||||
hostConfig.set("host.controller_url", this._controller.getControllerUrl());
|
||||
if (request.id !== undefined) {
|
||||
hostConfig.set("host.id", request.id);
|
||||
}
|
||||
if (request.name !== undefined) {
|
||||
hostConfig.set("host.name", request.name);
|
||||
}
|
||||
if (request.generateToken) {
|
||||
this.user.checkPermission("core.host.generate_token");
|
||||
hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id")));
|
||||
}
|
||||
return hostConfig.toJSON();
|
||||
}
|
||||
|
||||
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
|
||||
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
|
||||
}
|
||||
|
||||
async handleInstanceDetailsListRequest() {
|
||||
return [...this._controller.instances.values()].map(instance => instance.toInstanceDetails());
|
||||
}
|
||||
|
||||
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.update(request.config, false, "control");
|
||||
await this._controller.instanceCreate(instanceConfig);
|
||||
}
|
||||
|
||||
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
|
||||
await this._controller.instanceDelete(request.instanceId);
|
||||
}
|
||||
|
||||
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
return instance.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
if (request.field === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
|
||||
if (request.field === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
}
|
||||
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
let { field, prop, value } = request;
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
|
||||
await this._controller.instanceAssign(request.instanceId, request.hostId);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsListRequest() {
|
||||
return [...this._controller.saves.values()];
|
||||
}
|
||||
|
||||
async handleInstanceDownloadSaveRequest(request: lib.InstanceDownloadSaveRequest) {
|
||||
let { instanceId, name } = request;
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = name;
|
||||
|
||||
let ready = new Promise((resolve, reject) => {
|
||||
stream.events.on("source", resolve);
|
||||
stream.events.on("timeout", () => reject(
|
||||
new lib.RequestError("Timed out establishing stream from host")
|
||||
));
|
||||
});
|
||||
ready.catch(() => {});
|
||||
|
||||
await this._controller.sendToHostByInstanceId(new lib.InstancePushSaveRequest(
|
||||
instanceId,
|
||||
stream.id,
|
||||
name,
|
||||
));
|
||||
|
||||
await ready;
|
||||
return stream.id;
|
||||
}
|
||||
|
||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||
if (request.sourceInstanceId === request.targetInstanceId) {
|
||||
throw new lib.RequestError("Source and target instance may not be the same");
|
||||
}
|
||||
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
|
||||
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
||||
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
||||
if (sourceHostId === null) {
|
||||
throw new lib.RequestError("Source instance is not assigned a host");
|
||||
}
|
||||
if (targetHostId === null) {
|
||||
throw new lib.RequestError("Target instance is not assigned a host");
|
||||
}
|
||||
|
||||
// Let host handle request if source and target is on the same host.
|
||||
if (sourceHostId === targetHostId) {
|
||||
return await this._controller.sendTo({ hostId: sourceHostId }, request);
|
||||
}
|
||||
|
||||
// Check connectivity
|
||||
let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId);
|
||||
if (!sourceHostConnection || sourceHostConnection.connector.closing) {
|
||||
throw new lib.RequestError("Source host is not connected to the controller");
|
||||
}
|
||||
|
||||
let targetHostConnection = this._controller.wsServer.hostConnections.get(targetHostId);
|
||||
if (!targetHostConnection || targetHostConnection.connector.closing) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
}
|
||||
|
||||
// Create stream to proxy from target to source
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.events.on("timeout", () => {
|
||||
if (stream.source) {
|
||||
stream.source.destroy();
|
||||
}
|
||||
stream.events.emit("error", new lib.RequestError("Timed out establishing transfer stream"));
|
||||
});
|
||||
|
||||
// Ignore errors if not listening for them to avoid crash.
|
||||
stream.events.on("error", () => { /* ignore */ });
|
||||
|
||||
// Establish push from source host to stream, this is done first to
|
||||
// ensure the file size is known prior to the target host pull.
|
||||
await Promise.all([
|
||||
this._controller.sendTo(
|
||||
{ hostId: sourceHostId },
|
||||
new lib.InstancePushSaveRequest(request.sourceInstanceId, stream.id, request.sourceName),
|
||||
),
|
||||
events.once(stream.events, "source"),
|
||||
]);
|
||||
|
||||
// Establish pull from target host to stream and wait for completion.
|
||||
let storedName = await this._controller.sendTo(
|
||||
{ hostId: targetHostId },
|
||||
new lib.InstancePullSaveRequest(request.targetInstanceId, stream.id, request.targetName),
|
||||
);
|
||||
|
||||
// Delete source save if this is not a copy
|
||||
if (!request.copy) {
|
||||
await this._controller.sendTo(
|
||||
{ hostId: sourceHostId },
|
||||
new lib.InstanceDeleteSaveRequest(request.sourceInstanceId, request.sourceName),
|
||||
);
|
||||
}
|
||||
|
||||
return storedName;
|
||||
}
|
||||
|
||||
async handleModPackListRequest() {
|
||||
return [...this._controller.modPacks.values()];
|
||||
}
|
||||
|
||||
async handleModPackCreateRequest(request: lib.ModPackCreateRequest) {
|
||||
let modPack = request.modPack;
|
||||
if (modPack.id === undefined) {
|
||||
throw new lib.RequestError("Mod pack need an ID to be created");
|
||||
}
|
||||
if (this._controller.modPacks.has(modPack.id)) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`);
|
||||
}
|
||||
this._controller.modPacks.set(modPack.id, modPack);
|
||||
this._controller.modPacksUpdated([modPack]);
|
||||
}
|
||||
|
||||
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
|
||||
let modPack = request.modPack;
|
||||
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
|
||||
}
|
||||
this._controller.modPacks.set(modPack.id, modPack);
|
||||
this._controller.modPacksUpdated([modPack]);
|
||||
}
|
||||
|
||||
async handleModPackDeleteRequest(request: lib.ModPackDeleteRequest) {
|
||||
let { id } = request;
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
modPack.isDeleted = true;
|
||||
this._controller.modPacks.delete(id);
|
||||
this._controller.modPacksUpdated([modPack]);
|
||||
}
|
||||
|
||||
async handleModGetRequest(request: lib.ModGetRequest) {
|
||||
return this.getMod(request);
|
||||
}
|
||||
|
||||
async handleModListRequest() {
|
||||
return [...this._controller.modStore.mods()];
|
||||
}
|
||||
|
||||
static termsMatchesMod(terms: lib.ParsedTerm[], mod: lib.ModInfo) {
|
||||
for (let term of terms) {
|
||||
if (term.type === "word") {
|
||||
if (!lib.wordMatches(term,
|
||||
mod.name, mod.version, mod.title, mod.author, mod.contact,
|
||||
mod.homepage, mod.description, mod.filename
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
} else if (term.type === "attribute") {
|
||||
if (!lib.wordMatches(term.value, mod[term.name as keyof lib.ModInfo] as string)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleModSearchRequest(request: lib.ModSearchRequest) {
|
||||
let query = lib.parseSearchString(request.query, {
|
||||
name: "word",
|
||||
// version
|
||||
title: "word",
|
||||
author: "word",
|
||||
contact: "word",
|
||||
homepage: "word",
|
||||
description: "word",
|
||||
// factorioVersion
|
||||
// dependencies
|
||||
filename: "word",
|
||||
// size
|
||||
sha1: "word",
|
||||
});
|
||||
let factorioVersion = request.factorioVersion;
|
||||
|
||||
type ModVersions = { name: string, versions: lib.ModInfo[] };
|
||||
let results: Map<string, ModVersions> = new Map();
|
||||
for (let mod of this._controller.modStore.mods()) {
|
||||
if (
|
||||
mod.factorioVersion !== factorioVersion
|
||||
|| !ControlConnection.termsMatchesMod(query.terms, mod)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let result = results.get(mod.name);
|
||||
if (!result) {
|
||||
result = {
|
||||
name: mod.name,
|
||||
versions: [],
|
||||
};
|
||||
results.set(mod.name, result);
|
||||
}
|
||||
result.versions.push(mod);
|
||||
}
|
||||
for (let result of results.values()) {
|
||||
result.versions.sort((a, b) => b.integerVersion - a.integerVersion);
|
||||
result.versions.map(e => e.toJSON());
|
||||
}
|
||||
let resultList = [...results.values()];
|
||||
|
||||
const sort = request.sort;
|
||||
if (sort) {
|
||||
const sorters = {
|
||||
name: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].name, b.versions[0].name),
|
||||
title: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].title, b.versions[0].title),
|
||||
author: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].author, b.versions[0].author),
|
||||
};
|
||||
if (!Object.prototype.hasOwnProperty.call(sorters, sort)) {
|
||||
throw new lib.RequestError(`Invalid value for sort: ${sort}`);
|
||||
}
|
||||
resultList.sort(sorters[sort as keyof typeof sorters]);
|
||||
let order = request.sortOrder;
|
||||
if (order === "desc") {
|
||||
resultList.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
const page = request.page;
|
||||
const pageSize = request.pageSize || 10;
|
||||
resultList = resultList.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
return {
|
||||
queryIssues: query.issues,
|
||||
pageCount: Math.ceil(results.size / pageSize),
|
||||
resultCount: results.size,
|
||||
results: resultList,
|
||||
};
|
||||
}
|
||||
|
||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||
await this._controller.modStore.deleteMod(request.name, request.version);
|
||||
}
|
||||
|
||||
async handleLogSetSubscriptionsRequest(request: lib.LogSetSubscriptionsRequest) {
|
||||
this.logSubscriptions = {
|
||||
all: request.all || false,
|
||||
controller: request.controller || false,
|
||||
hostIds: request.hostIds || [],
|
||||
instanceIds: request.instanceIds || [],
|
||||
};
|
||||
this.updateLogSubscriptions();
|
||||
}
|
||||
|
||||
updateLogSubscriptions() {
|
||||
let { all, controller, hostIds, instanceIds } = this.logSubscriptions;
|
||||
if (all || controller || hostIds.length || instanceIds.length) {
|
||||
if (!this.logTransport) {
|
||||
this.logTransport = new lib.LinkTransport({ link: this });
|
||||
this._controller.clusterLogger.add(this.logTransport);
|
||||
}
|
||||
this.logTransport.filter = logFilter(this.logSubscriptions);
|
||||
|
||||
} else if (this.logTransport) {
|
||||
this._controller.clusterLogger.remove(this.logTransport);
|
||||
this.logTransport = null;
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogQueryRequest(request: lib.LogQueryRequest) {
|
||||
let _request = {
|
||||
limit: request.limit,
|
||||
order: request.order,
|
||||
maxLevel: request.maxLevel,
|
||||
all: request.all,
|
||||
controller: request.controller,
|
||||
hostIds: request.hostIds,
|
||||
instanceIds: request.instanceIds,
|
||||
};
|
||||
|
||||
let observeDuration = queryLogTime.startTimer();
|
||||
let { all, controller, hostIds, instanceIds } = request;
|
||||
|
||||
let log;
|
||||
if (!all && controller && !hostIds.length && !instanceIds.length) {
|
||||
log = await this._controller.queryControllerLog(_request);
|
||||
} else {
|
||||
log = await this._controller.queryClusterLog(_request);
|
||||
}
|
||||
|
||||
observeDuration();
|
||||
return { log };
|
||||
}
|
||||
|
||||
async handlePermissionListRequest() {
|
||||
return [...lib.permissions.values()];
|
||||
}
|
||||
|
||||
async handleRoleListRequest() {
|
||||
return [...this._controller.userManager.roles.values()];
|
||||
}
|
||||
|
||||
async handleRoleCreateRequest(request: lib.RoleCreateRequest) {
|
||||
let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]);
|
||||
|
||||
// Start at 5 to leave space for future default roles
|
||||
let id = Math.max(5, lastId+1);
|
||||
this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request }));
|
||||
this._controller.userManager.dirty = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
async handleRoleUpdateRequest(request: lib.RoleUpdateRequest) {
|
||||
let { id, name, description, permissions } = request;
|
||||
let role = this._controller.userManager.roles.get(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
|
||||
role.name = name;
|
||||
role.description = description;
|
||||
role.permissions = new Set(permissions);
|
||||
this._controller.rolePermissionsUpdated(role);
|
||||
}
|
||||
|
||||
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
|
||||
let role = this._controller.userManager.roles.get(request.id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
|
||||
}
|
||||
|
||||
role.grantDefaultPermissions();
|
||||
this._controller.rolePermissionsUpdated(role);
|
||||
}
|
||||
|
||||
async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) {
|
||||
let id = request.id;
|
||||
|
||||
if (!this._controller.userManager.roles.delete(id)) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.userManager.dirty = true;
|
||||
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
if (user.roleIds.delete(id)) {
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
|
||||
let name = request.name;
|
||||
let user = this._controller.userManager.users.get(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User ${name} does not exist`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async handleUserListRequest(): Promise<lib.User[]> {
|
||||
return [...this._controller.userManager.users.values()];
|
||||
}
|
||||
|
||||
async handleUserCreateRequest(request: lib.UserCreateRequest) {
|
||||
let user = this._controller.userManager.createUser(request.name);
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
||||
let user = this._controller.userManager.users.get(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
if (user.name !== this.user.name) {
|
||||
this.user.checkPermission("core.user.revoke_other_token");
|
||||
}
|
||||
|
||||
user.invalidateToken();
|
||||
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.name === user.name) {
|
||||
controlConnection.connector.terminate();
|
||||
}
|
||||
}
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
||||
let user = this._controller.userManager.users.get(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
|
||||
for (let roleId of request.roles) {
|
||||
if (!this._controller.userManager.roles.has(roleId)) {
|
||||
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.roleIds = new Set(request.roles);
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
|
||||
let { name, create, admin } = request;
|
||||
let user = this._controller.userManager.users.get(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isAdmin = admin;
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
|
||||
}
|
||||
|
||||
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
|
||||
let { name, create, banned, reason } = request;
|
||||
let user = this._controller.userManager.users.get(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isBanned = banned;
|
||||
user.banReason = reason;
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
|
||||
}
|
||||
|
||||
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
|
||||
let { name, create, whitelisted } = request;
|
||||
let user = this._controller.userManager.users.get(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isWhitelisted = whitelisted;
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
|
||||
}
|
||||
|
||||
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
|
||||
let name = request.name;
|
||||
let user = this._controller.userManager.users.get(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
|
||||
user.isDeleted = true;
|
||||
this._controller.userManager.users.delete(name);
|
||||
this._controller.usersUpdated([user]);
|
||||
|
||||
if (user.isAdmin) {
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
|
||||
}
|
||||
if (user.isWhitelisted) {
|
||||
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, false));
|
||||
}
|
||||
if (user.isBanned) {
|
||||
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, false, ""));
|
||||
}
|
||||
}
|
||||
|
||||
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
|
||||
this.ws_dumper = data => {
|
||||
if (this.connector.connected) {
|
||||
this.send(new lib.DebugWsMessageEvent(data.direction, data.content));
|
||||
}
|
||||
};
|
||||
this.connector._socket!.clusterio_ignore_dump = true;
|
||||
this._controller.debugEvents.on("message", this.ws_dumper);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ControlConnection;
|
||||
@@ -0,0 +1,132 @@
|
||||
import type Controller from "./Controller";
|
||||
import type BaseConnection from "./BaseConnection";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import HostConnection from "./HostConnection";
|
||||
import ControlConnection from "./ControlConnection";
|
||||
const { logger } = lib;
|
||||
|
||||
export default class ControllerRouter {
|
||||
constructor(
|
||||
public controller: Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param origin - Source link of the message.
|
||||
* @param message - Link the message originated from.
|
||||
* @param entry - message entry if message is a request or an event.
|
||||
* @param hasFallback - true if fallback handling is available.
|
||||
* @returns true if the message was handled
|
||||
*/
|
||||
forwardMessage(
|
||||
origin: ControlConnection | HostConnection,
|
||||
message: lib.MessageRoutable,
|
||||
entry: lib.RequestEntry | lib.EventEntry | undefined,
|
||||
hasFallback: boolean
|
||||
): boolean {
|
||||
if (!["request", "response", "responseError", "event"].includes(message.type)) {
|
||||
throw new Error(`Message type ${message.type} can't be forwarded`);
|
||||
}
|
||||
|
||||
let dst: lib.Address = message.dst;
|
||||
let nextHop: ControlConnection | HostConnection | undefined;
|
||||
let msg: string = "";
|
||||
if (dst.type === lib.Address.broadcast) {
|
||||
this.broadcastMessage(origin, message, entry as lib.EventEntry);
|
||||
return true;
|
||||
} else if (dst.type === lib.Address.host) {
|
||||
nextHop = this.controller.wsServer.hostConnections.get(dst.id);
|
||||
if (!nextHop) {
|
||||
msg = `Host ${dst.id} is offline`;
|
||||
}
|
||||
} else if (dst.type === lib.Address.instance) {
|
||||
let instance = this.controller.instances.get(dst.id);
|
||||
if (!instance) {
|
||||
msg = `Instance ${dst.id} does not exist`;
|
||||
} else {
|
||||
let assignedHost = instance.config.get("instance.assigned_host");
|
||||
if (assignedHost === null) {
|
||||
msg = `Instance ${dst.id} is not assigned a host`;
|
||||
} else {
|
||||
nextHop = this.controller.wsServer.hostConnections.get(assignedHost);
|
||||
if (!nextHop) {
|
||||
msg = `Assigned host for instance ${dst.id} is offline`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (dst.type === lib.Address.control) {
|
||||
nextHop = this.controller.wsServer.controlConnections.get(dst.id);
|
||||
if (!nextHop) {
|
||||
msg = "Control connection does not exist";
|
||||
}
|
||||
} else if (dst.type === lib.Address.controller) {
|
||||
msg = `Unexpected message forwarded to ${dst}`;
|
||||
}
|
||||
|
||||
if (nextHop === origin) {
|
||||
msg = `Message would return back to sender ${origin.connector.dst}.`;
|
||||
nextHop = undefined;
|
||||
}
|
||||
|
||||
if (message.type === "request") {
|
||||
if (!nextHop) {
|
||||
if (hasFallback) {
|
||||
return false;
|
||||
}
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(msg || "Unroutable destination"), message.src
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextHop) {
|
||||
if (message.type === "request") {
|
||||
nextHop.forwardRequest(message as lib.MessageRequest, origin);
|
||||
} else {
|
||||
nextHop.connector.forward(message);
|
||||
}
|
||||
} else {
|
||||
this.warnUnrouted(message, msg);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
broadcastMessage(
|
||||
origin: lib.Link,
|
||||
message: lib.MessageRoutable,
|
||||
entry: lib.EventEntry,
|
||||
) {
|
||||
let dst = message.dst;
|
||||
if (message.type !== "event") {
|
||||
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
|
||||
return;
|
||||
}
|
||||
const plugin = entry.Event.plugin;
|
||||
if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
|
||||
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
|
||||
if (hostConnection !== origin && (!plugin || hostConnection.plugins.has(plugin))) {
|
||||
hostConnection.connector.forward(message);
|
||||
}
|
||||
}
|
||||
} else if (dst.id === lib.Address.control) {
|
||||
for (let controlConnection of this.controller.wsServer.controlConnections.values()) {
|
||||
if (controlConnection !== origin) {
|
||||
controlConnection.connector.forward(message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
warnUnrouted(
|
||||
message: lib.MessageRoutable,
|
||||
msg: string
|
||||
) {
|
||||
let dst = message.dst;
|
||||
let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`;
|
||||
logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
|
||||
import type UserManager from "./UserManager";
|
||||
|
||||
export default class ControllerUser extends User implements IControllerUser {
|
||||
constructor(
|
||||
public userManager: UserManager,
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof User>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
...User.jsonSchema.properties,
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
|
||||
const roleIds = new Set<number>(json.roles ?? []);
|
||||
const instanceStats = new Map(
|
||||
(json.instance_stats ?? []).map(
|
||||
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
|
||||
)
|
||||
);
|
||||
const playerStats = User._calculatePlayerStats(instanceStats);
|
||||
return new this(
|
||||
userManager,
|
||||
json.token_valid_after,
|
||||
json.name,
|
||||
roleIds,
|
||||
new Set(json.instances),
|
||||
json.is_admin,
|
||||
json.is_banned,
|
||||
json.is_whitelisted,
|
||||
json.ban_reason,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
playerStats,
|
||||
instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
|
||||
if (!controller) {
|
||||
return super.toJSON();
|
||||
}
|
||||
|
||||
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
|
||||
if (this.tokenValidAfter) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this.userManager.roles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate current tokens for the user
|
||||
*
|
||||
* Sets the tokenValidAfter property to the current time, which causes
|
||||
* all currently issued tokens for the user to become invalid.
|
||||
*/
|
||||
invalidateToken() {
|
||||
this.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
checkPermission(permission: string) {
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`permission ${permission} does not exist`);
|
||||
}
|
||||
|
||||
for (let roleId of this.roleIds) {
|
||||
let role = this.userManager.roles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionError("Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import InstanceInfo from "./InstanceInfo";
|
||||
import HostInfo from "./HostInfo";
|
||||
|
||||
|
||||
/**
|
||||
* Represents the connection to a host
|
||||
*
|
||||
* @extends module:controller/src/BaseConnection
|
||||
* @alias module:controller/src/HostConnection
|
||||
*/
|
||||
export default class HostConnection extends BaseConnection {
|
||||
private info: HostInfo;
|
||||
plugins: Map<string, string>;
|
||||
|
||||
constructor(
|
||||
registerData: lib.RegisterHostData,
|
||||
connector: WsServerConnector,
|
||||
controller: Controller,
|
||||
remoteAddress: string,
|
||||
) {
|
||||
super(connector, controller);
|
||||
|
||||
this.plugins = new Map(Object.entries(registerData.plugins));
|
||||
|
||||
const previousHostInfo = this._controller.hosts.get(registerData.id);
|
||||
this.info = new HostInfo(
|
||||
registerData.id,
|
||||
previousHostInfo?.name ?? "",
|
||||
registerData.version,
|
||||
this.plugins,
|
||||
true,
|
||||
remoteAddress,
|
||||
previousHostInfo?.publicAddress,
|
||||
previousHostInfo?.tokenValidAfter,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
this._controller.hosts.set(this.id, this.info);
|
||||
this._controller.hostsUpdated([this.info]);
|
||||
|
||||
this._checkPluginVersions();
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
this.connector.on(event, () => {
|
||||
for (let plugin of this._controller.plugins.values()) {
|
||||
plugin.onHostConnectionEvent(this, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.connector.on("close", () => {
|
||||
const now = Date.now();
|
||||
// Update status to unknown for instances on this host.
|
||||
const instances: InstanceInfo[] = [];
|
||||
for (let instance of this._controller.instances.values()) {
|
||||
if (instance.config.get("instance.assigned_host") !== this.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
instances.push(instance);
|
||||
let prev = instance.status;
|
||||
instance.status = "unknown";
|
||||
instance.updatedAtMs = now;
|
||||
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
this._controller.instanceDetailsUpdated(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hostsUpdated([this.info]);
|
||||
});
|
||||
|
||||
this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this));
|
||||
this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
|
||||
this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
|
||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRoutable) {
|
||||
let origin = this.connector.dst;
|
||||
switch (message.src.type) {
|
||||
case lib.Address.control:
|
||||
case lib.Address.controller:
|
||||
throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`);
|
||||
|
||||
case lib.Address.host:
|
||||
if (message.src.id !== origin.id) {
|
||||
throw new lib.InvalidMessage(
|
||||
`Received message with invalid src ${message.src} from ${origin}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case lib.Address.instance:
|
||||
let instance = this._controller.instances.get(message.src.id);
|
||||
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
|
||||
throw new lib.InvalidMessage(
|
||||
`Received message with invalid src ${message.src} from ${origin}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("Should be unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
_checkPluginVersions() {
|
||||
let pluginInfos = new Map(this._controller.pluginInfos.map(i => [i.name, i]));
|
||||
for (let [name, version] of this.plugins) {
|
||||
let info = pluginInfos.get(name);
|
||||
if (!info) {
|
||||
logger.warn(
|
||||
`Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which the ` +
|
||||
"controller does not have",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.version !== version) {
|
||||
logger.warn(
|
||||
`Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which does not match ` +
|
||||
`the version of this plugin on the controller (${info.version})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let [name, info] of pluginInfos) {
|
||||
if (!this.plugins.has(name)) {
|
||||
logger.warn(`Host ${this.info.name} (${this.id}) is missing plugin ${name} ${info.version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prepareDisconnect() {
|
||||
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
|
||||
return await super.prepareDisconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* ID of the host this connection is connected to
|
||||
*
|
||||
* @returns {number} host ID.
|
||||
*/
|
||||
get id() {
|
||||
return this.info.id;
|
||||
}
|
||||
|
||||
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
|
||||
this.info.name = event.update.name;
|
||||
this.info.publicAddress = event.update.publicAddress;
|
||||
this._controller.hostsUpdated([this.info]);
|
||||
}
|
||||
|
||||
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
|
||||
let instance = this._controller.instances.get(request.instanceId);
|
||||
|
||||
// It's possible to get updates from an instance that does not exist
|
||||
// or is not assigned to the host it originated from if it was
|
||||
// reassigned or deleted while the connection to the host it was
|
||||
// originally on was down at the time.
|
||||
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
|
||||
logger.warn(`Got bogus update for instance id ${request.instanceId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We may receive status changed where the status hasn't changed
|
||||
// from our perspective if the connection was down at the time it
|
||||
// changed. Hosts also send status updates on assignInstance which
|
||||
// for hacky reason is also used to push config changes and
|
||||
// restablish status after a connection loss.
|
||||
if (
|
||||
instance.status === request.status
|
||||
&& instance.gamePort === request.gamePort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = request.status;
|
||||
instance.gamePort = request.gamePort;
|
||||
instance.updatedAtMs = Date.now();
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
this._controller.instanceDetailsUpdated([instance]);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
|
||||
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
|
||||
// Push updated instance configs
|
||||
for (let instance of this._controller.instances.values()) {
|
||||
if (instance.config.get("instance.assigned_host") === this.id) {
|
||||
await this.send(
|
||||
new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign instances the host has but controller does not
|
||||
const instanceUpdates: InstanceInfo[] = [];
|
||||
for (let instanceData of request.instances) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.update(instanceData.config, false, "host");
|
||||
|
||||
let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id"));
|
||||
if (controllerInstance) {
|
||||
// Check if this instance is assigned somewhere else.
|
||||
if (controllerInstance.config.get("instance.assigned_host") !== this.id) {
|
||||
await this.send(
|
||||
new lib.InstanceUnassignInternalRequest(instanceConfig.get("instance.id"))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already have this instance, update state instead
|
||||
if (controllerInstance.status !== instanceData.status) {
|
||||
let prev = controllerInstance.status;
|
||||
controllerInstance.status = instanceData.status;
|
||||
controllerInstance.gamePort = instanceData.gamePort;
|
||||
controllerInstance.updatedAtMs = Date.now();
|
||||
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
|
||||
instanceUpdates.push(controllerInstance);
|
||||
await lib.invokeHook(
|
||||
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceConfig.set("instance.assigned_host", this.id);
|
||||
let newInstance = new InstanceInfo(
|
||||
instanceConfig,
|
||||
instanceData.status,
|
||||
instanceData.gamePort,
|
||||
Date.now(),
|
||||
);
|
||||
this._controller.instances.set(instanceConfig.get("instance.id"), newInstance);
|
||||
this._controller.instancesDirty = true;
|
||||
this._controller.addInstanceHooks(newInstance);
|
||||
await this.send(
|
||||
new lib.InstanceAssignInternalRequest(
|
||||
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
|
||||
)
|
||||
);
|
||||
instanceUpdates.push(newInstance);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
|
||||
}
|
||||
|
||||
if (instanceUpdates.length) {
|
||||
this._controller.instanceDetailsUpdated(instanceUpdates);
|
||||
}
|
||||
|
||||
// Push lists to make sure they are in sync.
|
||||
let adminlist: Set<string> = new Set();
|
||||
let banlist: Map<string, string> = new Map();
|
||||
let whitelist: Set<string> = new Set();
|
||||
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
if (user.isAdmin) {
|
||||
adminlist.add(user.name);
|
||||
}
|
||||
if (user.isBanned) {
|
||||
banlist.set(user.name, user.banReason);
|
||||
}
|
||||
if (user.isWhitelisted) {
|
||||
whitelist.add(user.name);
|
||||
}
|
||||
}
|
||||
|
||||
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
|
||||
const now = Date.now();
|
||||
const updates: lib.SaveDetails[] = [];
|
||||
for (const save of event.updates) {
|
||||
const existingSave = this._controller.saves.get(save.id);
|
||||
if (existingSave && save.equals(existingSave)) {
|
||||
continue;
|
||||
}
|
||||
save.updatedAtMs = now;
|
||||
this._controller.saves.set(save.id, save);
|
||||
updates.push(save);
|
||||
}
|
||||
if (event.instanceId !== undefined) {
|
||||
const updatedSaves = new Set(event.updates.map(s => s.id));
|
||||
for (const [id, save] of this._controller.saves) {
|
||||
if (save.instanceId === event.instanceId && !updatedSaves.has(id)) {
|
||||
save.isDeleted = true;
|
||||
save.updatedAtMs = now;
|
||||
updates.push(save);
|
||||
this._controller.saves.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hosts eagerly send updates, which means we may get an update
|
||||
// where nothing actualy changed.
|
||||
if (updates.length) {
|
||||
this._controller.savesUpdated(updates);
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogMessageEvent(event: lib.LogMessageEvent) {
|
||||
this._controller.clusterLogger.log({
|
||||
...event.info,
|
||||
host_id: this.id,
|
||||
host_name: this.info.name,
|
||||
});
|
||||
}
|
||||
|
||||
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
|
||||
let instanceId = src.id;
|
||||
let user = this._controller.userManager.users.get(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
|
||||
if (event.type === "join") {
|
||||
this._controller.userManager.notifyJoin(user, instanceId);
|
||||
} else if (event.type === "leave") {
|
||||
this._controller.userManager.notifyLeave(user, instanceId);
|
||||
}
|
||||
|
||||
user.instanceStats.set(instanceId, event.stats);
|
||||
|
||||
user.recalculatePlayerStats();
|
||||
this._controller.usersUpdated([user]);
|
||||
|
||||
let instance = this._controller.instances.get(instanceId)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.type,
|
||||
name: event.name,
|
||||
reason: event.reason,
|
||||
stats: event.stats,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { HostDetails } from "@clusterio/lib";
|
||||
|
||||
/**
|
||||
* Info about a host known to the controller.
|
||||
*/
|
||||
export default class HostInfo {
|
||||
constructor(
|
||||
/** Id of this host */
|
||||
public id: number,
|
||||
/** Name of this host */
|
||||
public name: string,
|
||||
/** Version this host last connected with */
|
||||
public version: string,
|
||||
/** Plugins this host last connected with */
|
||||
public plugins: Map<string, string>,
|
||||
/** True if this host is currently connected to controller */
|
||||
public connected: boolean = false,
|
||||
/** IP this host last connected from */
|
||||
public remoteAddress: string = "",
|
||||
/** Value of host.public_address configured for this host */
|
||||
public publicAddress: string = "",
|
||||
/** Unix timestamp in seconds host token must be issued after to be valid */
|
||||
public tokenValidAfter: number = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs: number = 0,
|
||||
/** True if this host has been deleted */
|
||||
public isDeleted: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"id": Type.Number(),
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"plugins": Type.Record(Type.String(), Type.String()),
|
||||
"connected": Type.Boolean(),
|
||||
"remote_address": Type.String(),
|
||||
"public_address": Type.String(),
|
||||
"token_valid_after": Type.Number(),
|
||||
"updated_at_ms": Type.Number(),
|
||||
"is_deleted": Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.id,
|
||||
json.name,
|
||||
json.version,
|
||||
new Map(Object.entries(json.plugins)),
|
||||
json.connected,
|
||||
json.remote_address,
|
||||
json.public_address,
|
||||
json.token_valid_after,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Static<typeof HostInfo.jsonSchema> {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
plugins: Object.fromEntries(this.plugins),
|
||||
connected: this.connected,
|
||||
remote_address: this.remoteAddress,
|
||||
public_address: this.publicAddress,
|
||||
token_valid_after: this.tokenValidAfter,
|
||||
updated_at_ms: this.updatedAtMs,
|
||||
is_deleted: this.isDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
toHostDetails() {
|
||||
return new HostDetails(
|
||||
this.version,
|
||||
this.name,
|
||||
this.id,
|
||||
this.connected,
|
||||
this.remoteAddress,
|
||||
this.publicAddress,
|
||||
this.tokenValidAfter,
|
||||
this.updatedAtMs,
|
||||
this.isDeleted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Socket } from "net";
|
||||
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
|
||||
/**
|
||||
* Gracefully close a Node.js HTTP(S) server
|
||||
*
|
||||
* Helper class to properly close down a Node.js HTTP(S) server. This is
|
||||
* necessary due to connections being able to linger around forever with the
|
||||
* keep-alive logic in HTTP.
|
||||
*
|
||||
* @example
|
||||
* // during server initialization
|
||||
* const server = http.createServer();
|
||||
* const serverCloser = new HttpCloser(server);
|
||||
*
|
||||
* // in the shutdown logic
|
||||
* await serverCloser.close();
|
||||
*
|
||||
* @alias module:controller/src/HttpCloser
|
||||
*/
|
||||
export default class HttpCloser {
|
||||
private _server: http.Server | https.Server;
|
||||
private _sockets: Set<Socket>;
|
||||
private _responses: Set<http.ServerResponse>;
|
||||
|
||||
/**
|
||||
* Create HttpCloser attached to a server.
|
||||
* @param server - Node.js HTTP(S) server to attach to.
|
||||
*/
|
||||
constructor(server: http.Server | https.Server) {
|
||||
this._server = server;
|
||||
this._sockets = new Set();
|
||||
this._responses = new Set();
|
||||
|
||||
if (this._server instanceof https.Server) {
|
||||
this._server.on("secureConnection", this._addSocket.bind(this));
|
||||
} else if (this._server instanceof http.Server) {
|
||||
this._server.on("connection", this._addSocket.bind(this));
|
||||
} else {
|
||||
throw Error("server must be an instance of either http.Server or https.Server.");
|
||||
}
|
||||
this._server.on("request", this._addRequest.bind(this));
|
||||
}
|
||||
|
||||
_addSocket(socket:Socket) {
|
||||
this._sockets.add(socket);
|
||||
socket.once("close", () => {
|
||||
this._sockets.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
_addRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
this._responses.add(response);
|
||||
response.once("close", () => this._responses.delete(response));
|
||||
response.once("finish", () => this._responses.delete(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close down the attached HTTP(S) server.
|
||||
*
|
||||
* Closes open connections that do not have a request in flight, signals
|
||||
* connections to pending requests should close, and initiates the
|
||||
* close operation on the attached server.
|
||||
*
|
||||
* If there's requests in progress that still haven't been completed
|
||||
* after the timeout value passed they will be aborted.
|
||||
*
|
||||
* @param timeoutMs -
|
||||
* Time in ms to wait for connections to close.
|
||||
*/
|
||||
async close(timeoutMs: number = 5000): Promise<void> {
|
||||
let activeSockets = new Set();
|
||||
for (let response of this._responses) {
|
||||
if (!response.headersSent) {
|
||||
response.setHeader("Connection", "close");
|
||||
} else {
|
||||
let socket = response.socket!;
|
||||
response.once("finish", () => {
|
||||
socket.end();
|
||||
});
|
||||
}
|
||||
activeSockets.add(response.socket);
|
||||
}
|
||||
for (let socket of this._sockets) {
|
||||
if (!activeSockets.has(socket)) {
|
||||
socket.end();
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = setTimeout(() => {
|
||||
for (let socket of this._sockets) {
|
||||
// Ideally a RST would be sent here to indicate the the
|
||||
// transfer failed, but we can't reliably send that from
|
||||
// Node.js, see nodejs/node#27428.
|
||||
socket.destroy();
|
||||
}
|
||||
}, timeoutMs);
|
||||
this._server.close(err => {
|
||||
clearTimeout(timer);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
|
||||
/**
|
||||
* Runtime status of an instance on the controller
|
||||
* @alias module:controller/src/InstanceInfo
|
||||
*/
|
||||
export default class InstanceInfo {
|
||||
constructor(
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"config": lib.InstanceConfig.jsonSchema,
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
|
||||
return new this(
|
||||
lib.InstanceConfig.fromJSON(json.config, location),
|
||||
json.status,
|
||||
json.gamePort,
|
||||
json.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
toInstanceDetails() {
|
||||
return new lib.InstanceDetails(
|
||||
this.config.get("instance.name"),
|
||||
this.id,
|
||||
this.config.get("instance.assigned_host") ?? undefined,
|
||||
this.gamePort,
|
||||
this.status,
|
||||
this.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
/** Shorthand for `instance.config.get("instance.id")` */
|
||||
get id():number {
|
||||
return this.config.get("instance.id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "fs-extra";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControllerUser from "./ControllerUser";
|
||||
|
||||
/**
|
||||
* Manages users and roles
|
||||
* @alias module:controller/src/UserManager
|
||||
*/
|
||||
export default class UserManager {
|
||||
roles: Map<number, lib.Role> = new Map();
|
||||
users: Map<string, ControllerUser> = new Map();
|
||||
dirty = false;
|
||||
|
||||
/**
|
||||
* Set of users currently online in the cluster.
|
||||
*/
|
||||
onlineUsers = new Set<ControllerUser>();
|
||||
|
||||
constructor(
|
||||
private _config: lib.ControllerConfig
|
||||
) {
|
||||
}
|
||||
|
||||
async load(filePath: string): Promise<void> {
|
||||
try {
|
||||
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
|
||||
for (let serializedRole of content.roles) {
|
||||
let role = lib.Role.fromJSON(serializedRole);
|
||||
this.roles.set(role.id, role);
|
||||
}
|
||||
|
||||
for (let serializedUser of content.users) {
|
||||
let user = ControllerUser.fromJSON(serializedUser, this);
|
||||
this.users.set(user.name, user);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Create default roles if loading failed
|
||||
lib.ensureDefaultAdminRole(this.roles);
|
||||
lib.ensureDefaultPlayerRole(this.roles);
|
||||
}
|
||||
}
|
||||
|
||||
async save(filePath:string): Promise<void> {
|
||||
let serializedRoles = [];
|
||||
for (let role of this.roles.values()) {
|
||||
serializedRoles.push(role.toJSON());
|
||||
}
|
||||
|
||||
let serializedUsers = [];
|
||||
for (let user of this.users.values()) {
|
||||
serializedUsers.push(user.toJSON(true));
|
||||
}
|
||||
|
||||
let serialized = {
|
||||
users: serializedUsers,
|
||||
roles: serializedRoles,
|
||||
};
|
||||
this.dirty = false;
|
||||
await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user and add it to the user database.
|
||||
* @param name - Name of the user to create.
|
||||
* @returns The created user.
|
||||
*/
|
||||
createUser(name:string): ControllerUser {
|
||||
if (this.users.has(name)) {
|
||||
throw new Error(`User '${name}' already exists`);
|
||||
}
|
||||
|
||||
let roles = new Set<number>();
|
||||
let defaultRoleId = this._config.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
|
||||
roles.add(defaultRoleId);
|
||||
}
|
||||
|
||||
let user = new ControllerUser(this, 0, name, roles);
|
||||
this.users.set(name, user);
|
||||
this.dirty = true;
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign access token for the given user name
|
||||
*
|
||||
* @param name - user name to sign token for
|
||||
* @returns JWT access token for the user.
|
||||
*/
|
||||
signUserToken(name: string): string {
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: name },
|
||||
Buffer.from(this._config.get("controller.auth_secret"), "base64")
|
||||
);
|
||||
}
|
||||
|
||||
notifyJoin(user: ControllerUser, instance_id: number) {
|
||||
user.instances.add(instance_id);
|
||||
this.onlineUsers.add(user);
|
||||
}
|
||||
|
||||
notifyLeave(user: ControllerUser, instance_id: number) {
|
||||
user.instances.delete(instance_id);
|
||||
if (!user.instances.size) {
|
||||
this.onlineUsers.delete(user);
|
||||
}
|
||||
}
|
||||
|
||||
clearStatsOfInstance(instanceId: number) {
|
||||
for (const user of this.users.values()) {
|
||||
this.notifyLeave(user, instanceId);
|
||||
user.instanceStats.delete(instanceId);
|
||||
user.recalculatePlayerStats();
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type Controller from "./Controller";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
import ControlConnection from "./ControlConnection";
|
||||
import HostConnection from "./HostConnection";
|
||||
import WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import { version as packageVersion } from "../package.json";
|
||||
|
||||
const wsMessageCounter = new lib.Counter(
|
||||
"clusterio_controller_websocket_message_total",
|
||||
"How many messages have been received over WebSocket on the controller",
|
||||
{ labels: ["direction"] }
|
||||
);
|
||||
|
||||
const wsConnectionsCounter = new lib.Counter(
|
||||
"clusterio_controller_websocket_connections_total",
|
||||
"How many WebSocket connections have been initiated on the controller"
|
||||
);
|
||||
|
||||
const wsRejectedConnectionsCounter = new lib.Counter(
|
||||
"clusterio_controller_websocket_rejected_connections_total",
|
||||
"How many WebSocket connections have been rejected during the handshake on the controller"
|
||||
);
|
||||
|
||||
/**
|
||||
* WebSocket server
|
||||
* @alias module:controller/src/WsServer
|
||||
*/
|
||||
export default class WsServer {
|
||||
controller: Controller;
|
||||
stopAcceptingNewSessions: boolean = false;
|
||||
controlConnections: Map<number, ControlConnection> = new Map();
|
||||
hostConnections: Map<number, HostConnection> = new Map();
|
||||
activeConnectors: Map<number, WsServerConnector> = new Map();
|
||||
pendingSockets: Set<WebSocket> = new Set();
|
||||
// Unique string for the session token audience
|
||||
sessionAud: string = `session-${Date.now()}`;
|
||||
nextSessionId: number = 1;
|
||||
nextControlId: number = 1;
|
||||
wss: WebSocket.Server;
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controller = controller;
|
||||
|
||||
this.wss = new WebSocket.Server({
|
||||
noServer: true,
|
||||
path: "/api/socket",
|
||||
});
|
||||
this.wss.on("connection",
|
||||
(socket: lib.WebSocketClusterio, req: IncomingMessage) => this.handleConnection(socket, req),
|
||||
);
|
||||
}
|
||||
|
||||
remoteAddr(req: IncomingMessage) {
|
||||
const remote = req.socket.remoteAddress ?? "";
|
||||
if (
|
||||
remote && req.headers["x-forwarded-for"]
|
||||
&& (
|
||||
(req.socket.remoteFamily === "IPv4" && this.controller.trustedProxies.check(remote, "ipv4"))
|
||||
|| (req.socket.remoteFamily === "IPv6" && this.controller.trustedProxies.check(remote, "ipv6"))
|
||||
)
|
||||
) {
|
||||
return (req.headers["x-forwarded-for"] as string).split(",").slice(-1)[0].trim();
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopAcceptingNewSessions = true;
|
||||
|
||||
let disconnectTasks = [];
|
||||
for (let controlConnection of this.controlConnections.values()) {
|
||||
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
|
||||
}
|
||||
|
||||
for (let hostConnection of this.hostConnections.values()) {
|
||||
disconnectTasks.push(hostConnection.disconnect(1001, "Server Quit"));
|
||||
}
|
||||
|
||||
logger.info(`WsServer | Waiting for ${disconnectTasks.length} connectors to close`);
|
||||
for (let task of disconnectTasks) {
|
||||
try {
|
||||
await task;
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof lib.SessionLost)) {
|
||||
logger.warn(`Unexpected error disconnecting connector:\n${err.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let socket of this.pendingSockets) {
|
||||
socket.close(1001, "Server Quit");
|
||||
}
|
||||
}
|
||||
|
||||
handleUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer
|
||||
) {
|
||||
// For reasons that defy common sense, the connection event has
|
||||
// to be emitted explictly when using noServer.
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
this.wss.emit("connection", ws, req);
|
||||
});
|
||||
}
|
||||
|
||||
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
|
||||
logger.verbose(`WsServer | new connection from ${this.remoteAddr(req)}`);
|
||||
|
||||
// Track statistics
|
||||
wsConnectionsCounter.inc();
|
||||
socket.on("message", (message: WebSocket.RawData) => {
|
||||
wsMessageCounter.labels("in").inc();
|
||||
if (!socket.clusterio_ignore_dump) {
|
||||
this.controller.debugEvents.emit("message", { direction: "in", content: message.toString("utf8") });
|
||||
}
|
||||
});
|
||||
let originalSend = socket.send;
|
||||
socket.send = (...args: [string, any]) => {
|
||||
wsMessageCounter.labels("out").inc();
|
||||
if (typeof args[0] === "string" && !socket.clusterio_ignore_dump) {
|
||||
this.controller.debugEvents.emit("message", { direction: "out", content: args[0] });
|
||||
}
|
||||
return originalSend.call(socket, ...args);
|
||||
};
|
||||
|
||||
// Start connection handshake.
|
||||
let loadedPlugins: {[key:string]: string} = {};
|
||||
for (let [name, plugin] of this.controller.plugins) {
|
||||
loadedPlugins[name] = plugin.info.version;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
|
||||
this.attachHandler(socket, req);
|
||||
}
|
||||
|
||||
attachHandler(
|
||||
socket: WebSocket,
|
||||
req: IncomingMessage
|
||||
) {
|
||||
this.pendingSockets.add(socket);
|
||||
|
||||
let timeoutId = setTimeout(() => {
|
||||
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after timing out on handshake`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.terminate();
|
||||
this.pendingSockets.delete(socket);
|
||||
}, 30*1000);
|
||||
|
||||
socket.once("message", (message: string) => {
|
||||
clearTimeout(timeoutId);
|
||||
this.pendingSockets.delete(socket);
|
||||
this.handleHandshake(
|
||||
message, socket, req
|
||||
).catch(err => {
|
||||
logger.error(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured in WebSocket handshake, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1011, "Unexpected error");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleHandshake(rawMessage: string, socket: WebSocket, req: IncomingMessage) {
|
||||
let message;
|
||||
try {
|
||||
const json = JSON.parse(rawMessage);
|
||||
lib.Message.validate(json);
|
||||
message = lib.Message.fromJSON(json);
|
||||
} catch (err: any) {
|
||||
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid message`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1002, `Invalid message: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "resume") {
|
||||
this.resumeSession((message as lib.MessageResume).data, socket, req);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== "registerHost" && message.type !== "registerControl") {
|
||||
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid handshake`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1002, "Bad handshake");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopAcceptingNewSessions) {
|
||||
logger.verbose(`WsServer | closing ${this.remoteAddr(req)}, server is shutting down`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(1001, "Shutting down");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "registerHost") {
|
||||
await this.registerHost((message as lib.MessageRegisterHost).data, socket, req);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "registerControl") {
|
||||
await this.registerControl((message as lib.MessageRegisterControl).data, socket, req);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("This should be unreachable");
|
||||
}
|
||||
|
||||
createSession(dst: lib.Address): [ WsServerConnector, string ] {
|
||||
let sessionId = this.nextSessionId;
|
||||
this.nextSessionId += 1;
|
||||
let sessionToken = jwt.sign(
|
||||
{ aud: this.sessionAud, sid: sessionId },
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
);
|
||||
let sessionTimeout = this.controller.config.get("controller.session_timeout");
|
||||
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
|
||||
this.activeConnectors.set(sessionId, connector);
|
||||
connector.on("close", () => {
|
||||
this.activeConnectors.delete(sessionId);
|
||||
});
|
||||
|
||||
return [connector, sessionToken];
|
||||
}
|
||||
|
||||
async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) {
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
{ audience: ["host", "slave"] }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) {
|
||||
throw new Error("missmatched host id");
|
||||
}
|
||||
|
||||
let host = this.controller.hosts.get(data.id);
|
||||
if (!tokenPayload.iat || tokenPayload.iat < (host?.tokenValidAfter ?? 0)) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(4003, `Authentication failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
|
||||
let connection = this.hostConnections.get(data.id);
|
||||
if (connection) {
|
||||
logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`);
|
||||
await connection.disconnect(1008, "Registered from another connection");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`WsServer | registered host ${data.id} using version ${data.version}`
|
||||
);
|
||||
if (data.version !== packageVersion) {
|
||||
logger.warn(
|
||||
`Host ${data.id} connected using version ${data.version} which does not match the ` +
|
||||
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
|
||||
);
|
||||
}
|
||||
|
||||
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
|
||||
connector.on("close", () => {
|
||||
if (this.hostConnections.get(data.id) === connection) {
|
||||
this.hostConnections.delete(data.id);
|
||||
} else {
|
||||
logger.warn("Unlisted HostConnection closed");
|
||||
}
|
||||
});
|
||||
this.hostConnections.set(data.id, connection);
|
||||
let src = new lib.Address(lib.Address.host, data.id);
|
||||
connector.ready(socket, src, sessionToken, undefined);
|
||||
}
|
||||
|
||||
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
|
||||
let user;
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
user = this.controller.userManager.users.get(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
if ((tokenPayload.iat || 0) < user.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
user.checkPermission("core.control.connect");
|
||||
|
||||
} catch (err: any) {
|
||||
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(4003, `Authentication failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this.nextControlId;
|
||||
this.nextControlId += 1;
|
||||
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.control, id));
|
||||
|
||||
logger.verbose(`WsServer | registered control ${id} from ${this.remoteAddr(req)}`);
|
||||
let connection = new ControlConnection(data, connector, this.controller, user, id);
|
||||
connector.on("close", () => {
|
||||
this.controlConnections.delete(id);
|
||||
});
|
||||
this.controlConnections.set(id, connection);
|
||||
let account = new lib.AccountDetails(
|
||||
user.name,
|
||||
[...user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
}))
|
||||
);
|
||||
|
||||
let src = new lib.Address(lib.Address.control, id);
|
||||
connector.ready(socket, src, sessionToken, account);
|
||||
}
|
||||
|
||||
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
|
||||
let connector;
|
||||
try {
|
||||
let payload = jwt.verify(
|
||||
data.sessionToken,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: this.sessionAud }
|
||||
);
|
||||
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
connector = this.activeConnectors.get(payload.sid);
|
||||
if (!connector) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
socket.send(JSON.stringify(new lib.MessageInvalidate()));
|
||||
this.attachHandler(socket, req);
|
||||
return;
|
||||
}
|
||||
|
||||
connector.continue(socket, data.lastSeq as number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type WebSocket from "ws";
|
||||
|
||||
import events from "events";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
|
||||
/**
|
||||
* Connector for controller connections
|
||||
*
|
||||
* @extends module:lib.WebSocketBaseConnector
|
||||
* @alias module:controller/src/WsServerConnector
|
||||
*/
|
||||
export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
_timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
dst: lib.Address,
|
||||
private _sessionId: number,
|
||||
private _sessionTimeout: number,
|
||||
public _heartbeatInterval: number,
|
||||
) {
|
||||
super(new lib.Address(lib.Address.controller, 0), dst);
|
||||
|
||||
// The following states are used in the server connector
|
||||
// closed: Connection is closed
|
||||
// connected: Connection is online
|
||||
// resuming: Waiting for client to resume.
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this._timeoutId) {
|
||||
clearTimeout(this._timeoutId);
|
||||
this._timeoutId = null;
|
||||
}
|
||||
super._reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ready over the socket
|
||||
*
|
||||
* Sends the ready message over the socket to initiate the session.
|
||||
*
|
||||
* @param socket - WebSocket connection to client.
|
||||
* @param src - Source address for this link.
|
||||
* @param sessionToken - the session token to send to the client.
|
||||
* @param account - account data to provide to control connection
|
||||
*/
|
||||
ready(
|
||||
socket: WebSocket.WebSocket,
|
||||
src: lib.Address,
|
||||
sessionToken: string,
|
||||
account: lib.AccountDetails | undefined
|
||||
) {
|
||||
this._socket = socket;
|
||||
this._sendInternal(new lib.MessageReady(
|
||||
new lib.ReadyData(
|
||||
src,
|
||||
sessionToken,
|
||||
this._sessionTimeout,
|
||||
this._heartbeatInterval,
|
||||
account,
|
||||
),
|
||||
));
|
||||
|
||||
this._state = "connected";
|
||||
this._attachSocketHandlers();
|
||||
this.emit("connect", { src: this.src });
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue connection with the given socket
|
||||
*
|
||||
* Terminates the current socket and contiunes the session over the
|
||||
* socket given from the message sequence given.
|
||||
*
|
||||
* @param socket - New socket to continue on.
|
||||
* @param lastSeq - The last message the client received.
|
||||
*/
|
||||
continue(
|
||||
socket: WebSocket.WebSocket,
|
||||
lastSeq: number
|
||||
) {
|
||||
|
||||
// It's possible the previous connection hasn't closed yet due to a
|
||||
// stale connection. Terminate it if so.
|
||||
if (this._state === "connected" && this._socket !== null) {
|
||||
this._socket.terminate();
|
||||
this._socket.once("close", () => this.continue(socket, lastSeq));
|
||||
return;
|
||||
}
|
||||
|
||||
this._socket = socket;
|
||||
|
||||
if (this._timeoutId) {
|
||||
clearTimeout(this._timeoutId);
|
||||
this._timeoutId = null;
|
||||
}
|
||||
|
||||
this._sendInternal(new lib.MessageContinue(
|
||||
new lib.ContinueData(this._sessionTimeout, this._heartbeatInterval, this._lastReceivedSeq)
|
||||
));
|
||||
|
||||
this._state = "connected";
|
||||
this._attachSocketHandlers();
|
||||
this._dropSendBufferSeq(lastSeq);
|
||||
for (let message of this._sendBuffer) {
|
||||
this._socket.send(JSON.stringify(message));
|
||||
}
|
||||
this.emit("resume");
|
||||
}
|
||||
|
||||
_timedOut() {
|
||||
logger.verbose("Connector | Connection timed out");
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
_attachSocketHandlers() {
|
||||
if (this._socket === null) {
|
||||
throw new Error("this._socket is null");
|
||||
}
|
||||
|
||||
this.startHeartbeat();
|
||||
|
||||
this._socket.on("close", (code: number, reason: string) => {
|
||||
logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`);
|
||||
this.stopHeartbeat();
|
||||
this._socket = null;
|
||||
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
|
||||
} else {
|
||||
this._state = "resuming";
|
||||
this.emit("drop");
|
||||
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
this._socket.on("error", (err: any) => {
|
||||
// It's assumed that close is always called by ws
|
||||
logger.verbose("Connector | Error:", err);
|
||||
});
|
||||
|
||||
this._socket.on("open", () => {
|
||||
logger.verbose("Connector | Open");
|
||||
});
|
||||
this._socket.on("ping", (data: string) => {
|
||||
logger.verbose(`Connector | Ping (data: ${data}`);
|
||||
});
|
||||
this._socket.on("pong", (data: string) => {
|
||||
logger.verbose(`Connector | Pong (data: ${data}`);
|
||||
});
|
||||
|
||||
// Handle messages
|
||||
this._socket.on("message", (data: string) => {
|
||||
let message = this._parseMessage(data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (this._state === "connected") {
|
||||
this._processMessage(message);
|
||||
|
||||
} else {
|
||||
throw new Error(`Received message in unexpected state ${this._state}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection with the given reason.
|
||||
*
|
||||
* Sends a close frame and disconnects the connector.
|
||||
*
|
||||
* @param code - WebSocket close code.
|
||||
* @param reason - WebSocket close reason.
|
||||
*/
|
||||
async close(
|
||||
code: number,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
if (this._state === "closed") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state === "resuming") {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
return;
|
||||
}
|
||||
|
||||
this._closing = true;
|
||||
if (this._socket !== null) {
|
||||
this._socket.close(code, reason);
|
||||
}
|
||||
await events.once(this, "close");
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcefully close the connection immediately
|
||||
*
|
||||
* This should only be used if there's a security implication from
|
||||
* letting the connection carry on with a normal close proceedure.
|
||||
*/
|
||||
terminate() {
|
||||
this.setClosing();
|
||||
|
||||
if (this._socket) {
|
||||
this._socket.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the connection is expected to close
|
||||
*
|
||||
* Sets the internal flag to signal not to wait for the client to
|
||||
* reconnect and resume the connection if the client closes it. This
|
||||
* will close the connection if it's currently waiting for the client to
|
||||
* resume it.
|
||||
*/
|
||||
setClosing() {
|
||||
if (this._state === "resuming") {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
return;
|
||||
}
|
||||
|
||||
this._closing = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import util from "util";
|
||||
|
||||
export const endpointHitCounter = new lib.Counter(
|
||||
"clusterio_controller_http_endpoint_hits_total",
|
||||
"How many requests a particular HTTP endpoint has gotten",
|
||||
{ labels: ["route"], register: false }
|
||||
);
|
||||
|
||||
endpointHitCounter.labels = util.deprecate(
|
||||
endpointHitCounter.labels,
|
||||
"incrementing endpointHitCounter is no longer needed and has no effect"
|
||||
);
|
||||
@@ -0,0 +1,693 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import type Controller from "./Controller";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import JSZip from "jszip";
|
||||
import jwt from "jsonwebtoken";
|
||||
import path from "path";
|
||||
import nodeStream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
const finished = util.promisify(nodeStream.finished);
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
export interface Locals {
|
||||
controller: Controller,
|
||||
mainBundle: string,
|
||||
devPlugins: Map<string, number>,
|
||||
streams: Map<string, ProxyStream>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merges samples from sourceResult to destinationResult
|
||||
function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceResult: lib.CollectorResultSerialized) {
|
||||
let receivedSamples = new Map(sourceResult.samples);
|
||||
for (let [suffix, suffixSamples] of destinationResult.samples) {
|
||||
if (receivedSamples.has(suffix)) {
|
||||
suffixSamples.push(...receivedSamples.get(suffix) as any[]);
|
||||
receivedSamples.delete(suffix);
|
||||
}
|
||||
}
|
||||
|
||||
for (let entry of receivedSamples) {
|
||||
destinationResult.samples.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Prometheus polling endpoint
|
||||
async function getMetrics(req: Request, res: Response, next: any) {
|
||||
const controller: Controller = req.app.locals.controller;
|
||||
|
||||
let results: lib.CollectorResult[] = [];
|
||||
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
|
||||
for (let metricIterator of pluginResults) {
|
||||
for await (let metric of metricIterator) {
|
||||
results.push(metric);
|
||||
}
|
||||
}
|
||||
|
||||
let requests: Promise<InstanceType<typeof lib.HostMetricsRequest["Response"]> | null>[] = [];
|
||||
let timeoutMs = controller.config.get("controller.metrics_timeout") * 1000;
|
||||
for (let [hostId, hostConnection] of controller.wsServer.hostConnections) {
|
||||
if (!hostConnection.connected) {
|
||||
continue;
|
||||
}
|
||||
requests.push(lib.timeout(
|
||||
hostConnection.send(new lib.HostMetricsRequest()).catch((err: any) => {
|
||||
if (!(err instanceof lib.SessionLost)) {
|
||||
logger.error(`Unexpected error gathering metrics from host:\n${err.stack}`);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
timeoutMs, null
|
||||
));
|
||||
}
|
||||
|
||||
for await (let result of lib.defaultRegistry.collect()) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let resultMap: Map<string, lib.CollectorResultSerialized> = new Map();
|
||||
for (let response of await Promise.all(requests)) {
|
||||
if (!response) {
|
||||
// TODO: Log timeout occured?
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let result of response.results) {
|
||||
if (!resultMap.has(result.metric.name)) {
|
||||
resultMap.set(result.metric.name, result);
|
||||
|
||||
} else {
|
||||
// Merge metrics received by multiple hosts
|
||||
mergeSamples(resultMap.get(result.metric.name)!, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let result of resultMap.values()) {
|
||||
results.push(lib.deserializeResult(result));
|
||||
}
|
||||
|
||||
let text = await lib.exposition(results);
|
||||
res.set("Content-Type", lib.expositionContentType);
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
|
||||
function getPlugins(req: Request, res: Response) {
|
||||
let plugins: lib.PluginWebApi[] = [];
|
||||
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
|
||||
let name = pluginInfo.name;
|
||||
let loaded = req.app.locals.controller.plugins.has(name);
|
||||
let enabled = loaded && req.app.locals.controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
|
||||
) as unknown as boolean;
|
||||
// Note: Cast through unknown is needed because load_plugin is
|
||||
// defined at runtime and no other fields in the controller config
|
||||
// currently have the boolean type.
|
||||
let web: { main:any, error:string|undefined } = { main: undefined, error: undefined};
|
||||
let devPlugins = req.app.locals.devPlugins;
|
||||
if (devPlugins && devPlugins.has(name)) {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
|
||||
web.main = stats.toJson().assetsByChunkName[name];
|
||||
} else if (pluginInfo.manifest) {
|
||||
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
|
||||
if (!web.main) {
|
||||
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
|
||||
}
|
||||
} else {
|
||||
web.error = "Missing dist/web/manifest.json";
|
||||
}
|
||||
if (web.main === "remoteEntry.js") {
|
||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
||||
}
|
||||
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
|
||||
}
|
||||
res.send(plugins);
|
||||
}
|
||||
|
||||
function validateHostToken(req: Request, res: Response, next: any) {
|
||||
let token = req.header("x-access-token");
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "host" }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let host = req.app.locals.controller.hosts.get(tokenPayload.host);
|
||||
if (!host) {
|
||||
throw new Error("invalid host");
|
||||
}
|
||||
if (!tokenPayload.iat || tokenPayload.iat < (host.tokenValidAfter ?? 0)) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
} catch (err) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function validateUserToken(req: Request, res: Response, next: any) {
|
||||
let token = req.header("x-access-token");
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let user = req.app.locals.controller.userManager.users.get(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
res.locals.user = user;
|
||||
|
||||
} catch (err) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Handle an uploaded export package.
|
||||
async function uploadExport(req: Request, res: Response) {
|
||||
if (req.get("Content-Type") !== "application/zip") {
|
||||
res.sendStatus(415);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.query.mod_pack_id !== "string") {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
let modPackId = Number.parseInt(req.query.mod_pack_id, 10);
|
||||
if (!Number.isInteger(modPackId)) {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
let modPack = res.app.locals.controller.modPacks.get(modPackId);
|
||||
if (!modPack) {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = [];
|
||||
for await (let chunk of req) {
|
||||
data.push(chunk);
|
||||
}
|
||||
let buffer: Buffer | null = Buffer.concat(data);
|
||||
let zip = await JSZip.loadAsync(buffer);
|
||||
buffer = null;
|
||||
|
||||
// This is hardcoded to prevent path expansion attacks
|
||||
let exportFiles = [
|
||||
"export/settings.json",
|
||||
"export/prototypes.json",
|
||||
"export/item-spritesheet.png",
|
||||
"export/item-metadata.json",
|
||||
"export/locale.json",
|
||||
];
|
||||
|
||||
let assets: any = {};
|
||||
let settingPrototypes = {};
|
||||
for (let filePath of exportFiles) {
|
||||
let file = zip.file(filePath);
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filePath === "export/settings.json") {
|
||||
settingPrototypes = JSON.parse(await file.async("text"));
|
||||
}
|
||||
|
||||
let { name, ext } = path.posix.parse(filePath);
|
||||
let hash = await lib.hashStream(file.nodeStream());
|
||||
assets[name] = `${name}.${hash}${ext}`;
|
||||
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
|
||||
}
|
||||
|
||||
modPack.exportManifest = new lib.ExportManifest(assets);
|
||||
modPack.fillDefaultSettings(settingPrototypes, logger);
|
||||
res.app.locals.controller.modPacksUpdated([modPack]);
|
||||
|
||||
res.sendStatus(200);
|
||||
}
|
||||
|
||||
|
||||
export interface ProxyStream {
|
||||
id: string;
|
||||
source: nodeStream.Readable | null;
|
||||
flowing: boolean;
|
||||
size?: string;
|
||||
mime?: string;
|
||||
filename: string | null;
|
||||
events: events.EventEmitter;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export async function createProxyStream(app: Application): Promise<ProxyStream> {
|
||||
let asyncRandomBytes = util.promisify(crypto.randomBytes);
|
||||
let id = (await asyncRandomBytes(8)).toString("hex");
|
||||
let stream: ProxyStream = {
|
||||
id,
|
||||
source: null,
|
||||
flowing: false,
|
||||
size: undefined,
|
||||
mime: undefined,
|
||||
filename: null,
|
||||
events: new events.EventEmitter(),
|
||||
timeout: setTimeout(() => {
|
||||
stream.events.emit("timeout");
|
||||
}, app.locals.controller.config.get("controller.proxy_stream_timeout") * 1000),
|
||||
};
|
||||
stream.events.on("close", () => {
|
||||
clearTimeout(stream.timeout);
|
||||
app.locals.streams.delete(id);
|
||||
});
|
||||
stream.events.on("timeout", () => {
|
||||
stream.events.emit("close");
|
||||
});
|
||||
app.locals.streams.set(id, stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function putStream(req: Request, res: Response) {
|
||||
const stream = req.app.locals.streams.get(req.params.id);
|
||||
if (!stream || stream.source) {
|
||||
res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
stream.source = req;
|
||||
stream.mime = req.get("Content-Type");
|
||||
stream.size = req.get("Content-Length");
|
||||
|
||||
stream.events.emit("source");
|
||||
stream.events.on("close", () => {
|
||||
if (!stream.flowing) {
|
||||
req.resume();
|
||||
res.sendStatus(500);
|
||||
} else {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startStream(res: Response, stream: ProxyStream) {
|
||||
res.append("Content-Type", stream.mime);
|
||||
if (stream.size) {
|
||||
res.append("Content-Length", stream.size);
|
||||
}
|
||||
if (stream.filename) {
|
||||
res.append("Content-Disposition", `attachment; filename="${stream.filename}"`);
|
||||
} else {
|
||||
res.append("Content-Disposition", "attachment");
|
||||
}
|
||||
stream.source!.pipe(res);
|
||||
stream.flowing = true;
|
||||
res.on("close", () => {
|
||||
stream.events.emit("close");
|
||||
});
|
||||
clearTimeout(stream.timeout);
|
||||
}
|
||||
|
||||
async function getStream(req: Request, res: Response) {
|
||||
const stream = req.app.locals.streams.get(req.params.id);
|
||||
if (!stream || stream.flowing) {
|
||||
res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream.source) {
|
||||
startStream(res, stream);
|
||||
} else {
|
||||
stream.events.on("source", () => startStream(res, stream));
|
||||
stream.events.on("timeout", () => {
|
||||
res.sendStatus(500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const zipMimes = [
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
];
|
||||
|
||||
const contentTypeRegExp = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/;
|
||||
|
||||
async function uploadSave(req: Request, res: Response) {
|
||||
try {
|
||||
res.locals.user.checkPermission("core.instance.save.upload");
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ request_errors: [err.message] });
|
||||
return;
|
||||
}
|
||||
|
||||
let contentType = req.get("Content-Type") || "";
|
||||
let match = contentTypeRegExp.exec(contentType);
|
||||
if (!match) {
|
||||
res.status(415).json({ request_errors: ["invalid Content-Type"] });
|
||||
return;
|
||||
}
|
||||
let contentMime = match[1].toLowerCase();
|
||||
|
||||
let tasks = [];
|
||||
let errors: string[] = [];
|
||||
let requestErrors = [];
|
||||
let saves: string[] = [];
|
||||
|
||||
async function handleFile(instanceId:number, stream: nodeStream.Readable, filename: string, streamMime: string) {
|
||||
let proxyStream = await createProxyStream(req.app);
|
||||
proxyStream.source = stream;
|
||||
proxyStream.mime = streamMime;
|
||||
let timeout = new Promise<never>((_, reject) => {
|
||||
proxyStream.events.on("timeout", () => {
|
||||
stream.resume();
|
||||
reject(new Error("Timed out establishing stream to host"));
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
let storedName: string = await Promise.race([
|
||||
req.app.locals.controller.sendToHostByInstanceId(
|
||||
new lib.InstancePullSaveRequest(
|
||||
instanceId,
|
||||
proxyStream.id,
|
||||
filename,
|
||||
)
|
||||
),
|
||||
timeout,
|
||||
]);
|
||||
saves.push(storedName);
|
||||
|
||||
} catch (err: any) {
|
||||
proxyStream.events.emit("close");
|
||||
logger.error(`Error uploading save: ${err.message}`);
|
||||
errors.push(err.message);
|
||||
stream.resume();
|
||||
}
|
||||
}
|
||||
|
||||
if (contentMime === "multipart/form-data") {
|
||||
await new Promise(resolve => {
|
||||
let instanceId: number | undefined;
|
||||
function fileHandler(
|
||||
name: string,
|
||||
stream: nodeStream.Readable,
|
||||
{ filename, mimeType }: { filename: string, mimeType: string }
|
||||
) {
|
||||
if (instanceId === undefined) {
|
||||
requestErrors.push("instance_id must come before files uploaded");
|
||||
}
|
||||
if (!zipMimes.includes(mimeType)) {
|
||||
requestErrors.push("invalid file Content-Type");
|
||||
}
|
||||
if (!filename.endsWith(".zip")) {
|
||||
requestErrors.push("filename must end with .zip");
|
||||
}
|
||||
if (errors.length || requestErrors.length) {
|
||||
stream.resume();
|
||||
return;
|
||||
}
|
||||
tasks.push(handleFile(instanceId as number, stream, filename, mimeType));
|
||||
}
|
||||
let parser = busboy({ headers: req.headers });
|
||||
parser.on("file", fileHandler);
|
||||
parser.on("field", (name, value, info) => {
|
||||
if (name === "instance_id") {
|
||||
instanceId = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(instanceId)) {
|
||||
requestErrors.push("invalid instance_id");
|
||||
}
|
||||
}
|
||||
});
|
||||
parser.on("close", resolve);
|
||||
parser.on("error", (err: any) => {
|
||||
logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`);
|
||||
errors.push(err.message);
|
||||
});
|
||||
req.pipe(parser);
|
||||
});
|
||||
|
||||
} else if (zipMimes.includes(contentMime)) {
|
||||
let filename = req.query.filename;
|
||||
if (typeof filename !== "string") {
|
||||
requestErrors.push("Missing or invalid filename parameter");
|
||||
} else if (!filename.endsWith(".zip")) {
|
||||
requestErrors.push("filename must end with .zip");
|
||||
}
|
||||
let instanceId = Number.parseInt(String(req.query.instance_id), 10);
|
||||
if (Number.isNaN(instanceId)) {
|
||||
requestErrors.push("Missing or invalid instance_id parameter");
|
||||
}
|
||||
|
||||
if (errors.length || requestErrors.length) {
|
||||
req.resume();
|
||||
} else {
|
||||
tasks.push(handleFile(instanceId, req, filename as string, contentMime));
|
||||
}
|
||||
|
||||
} else {
|
||||
res.status(415).json({ request_errors: ["invalid Content-Type"] });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
if (errors.length) {
|
||||
res.status(500);
|
||||
res.json({ errors, request_errors: requestErrors });
|
||||
return;
|
||||
}
|
||||
if (requestErrors.length) {
|
||||
res.status(400);
|
||||
res.json({ request_errors: requestErrors });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ saves });
|
||||
}
|
||||
|
||||
function checkModName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Mod name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMod(req: Request, res: Response) {
|
||||
try {
|
||||
res.locals.user.checkPermission("core.mod.upload");
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ request_errors: [err.message] });
|
||||
return;
|
||||
}
|
||||
|
||||
let contentType = req.get("Content-Type")!;
|
||||
let match = contentTypeRegExp.exec(contentType);
|
||||
if (!match) {
|
||||
res.status(415).json({ request_errors: ["invalid Content-Type"] });
|
||||
return;
|
||||
}
|
||||
let contentMime = match[1].toLowerCase();
|
||||
|
||||
let tasks = [];
|
||||
let errors: string[] = [];
|
||||
let requestErrors = [];
|
||||
let mods: any[] = [];
|
||||
|
||||
async function handleFile(stream: nodeStream.Readable, filename: string) {
|
||||
try {
|
||||
checkModName(filename);
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Error uploading mod: ${err.message}`);
|
||||
errors.push(err.message);
|
||||
stream.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
|
||||
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
|
||||
let writeStream;
|
||||
try {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
|
||||
checkModName(modInfo.filename);
|
||||
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
|
||||
req.app.locals.controller.modStore.addMod(modInfo);
|
||||
mods.push(modInfo.toJSON());
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Error uploading mod: ${err.message}`);
|
||||
errors.push(err.message);
|
||||
stream.resume();
|
||||
|
||||
// Attempt to clean up.
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.unlink(path.join(modsDirectory, tempFilename));
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") {
|
||||
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentMime === "multipart/form-data") {
|
||||
await new Promise(resolve => {
|
||||
function fileHandler(
|
||||
name: string,
|
||||
stream: nodeStream.Readable,
|
||||
{ filename, mimeType }: { filename: string, mimeType: string }
|
||||
) {
|
||||
if (!zipMimes.includes(mimeType)) {
|
||||
requestErrors.push("invalid file Content-Type");
|
||||
}
|
||||
if (!filename.endsWith(".zip")) {
|
||||
requestErrors.push("filename must end with .zip");
|
||||
}
|
||||
if (errors.length || requestErrors.length) {
|
||||
stream.resume();
|
||||
return;
|
||||
}
|
||||
tasks.push(handleFile(stream, filename));
|
||||
}
|
||||
|
||||
let parser = busboy({ headers: req.headers });
|
||||
parser.on("file", fileHandler);
|
||||
parser.on("close", resolve);
|
||||
parser.on("error", (err: any) => {
|
||||
logger.error(`Error parsing multipart request in upload-mod:\n${err.stack}`);
|
||||
errors.push(err.message);
|
||||
});
|
||||
req.pipe(parser);
|
||||
});
|
||||
|
||||
} else if (zipMimes.includes(contentMime)) {
|
||||
let filename = req.query.filename;
|
||||
if (typeof filename !== "string") {
|
||||
requestErrors.push("Missing or invalid filename parameter");
|
||||
} else if (!filename.endsWith(".zip")) {
|
||||
requestErrors.push("filename must end with .zip");
|
||||
}
|
||||
|
||||
if (errors.length || requestErrors.length) {
|
||||
req.resume();
|
||||
} else {
|
||||
tasks.push(handleFile(req, filename as string));
|
||||
}
|
||||
|
||||
} else {
|
||||
res.status(415).json({ request_errors: ["invalid Content-Type"] });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
if (errors.length) {
|
||||
res.status(500);
|
||||
res.json({ errors, request_errors: requestErrors });
|
||||
return;
|
||||
}
|
||||
if (requestErrors.length) {
|
||||
res.status(400);
|
||||
res.json({ request_errors: requestErrors });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ mods });
|
||||
}
|
||||
|
||||
|
||||
export function addRouteHandlers(app: Application) {
|
||||
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
|
||||
app.get("/api/plugins", getPlugins);
|
||||
app.put("/api/upload-export",
|
||||
validateHostToken,
|
||||
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
|
||||
);
|
||||
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
|
||||
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
|
||||
app.post("/api/upload-save",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
|
||||
);
|
||||
app.post("/api/upload-mod",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => uploadMod(req, res).catch(next)
|
||||
);
|
||||
}
|
||||
|
||||
// Routes used in the web interface and served by the controller
|
||||
export const webRoutes = [
|
||||
"/",
|
||||
"/controller",
|
||||
"/hosts",
|
||||
"/hosts/:id/view",
|
||||
"/instances",
|
||||
"/instances/:id/view",
|
||||
"/mods",
|
||||
"/mods/mod-packs/:id/view",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
"/roles/:id/view",
|
||||
"/plugins",
|
||||
"/plugins/:name/view",
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.browser.json" },
|
||||
{ "path": "../web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2a2a2a" />
|
||||
<title>__CLUSTER_NAME__</title>
|
||||
<style>
|
||||
html {
|
||||
color: #fff;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
#bootstrap-msg {
|
||||
font: 20px serif;
|
||||
color: #aaa;
|
||||
margin: auto;
|
||||
margin-top: 1em;
|
||||
width: fit-content;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root">
|
||||
<div id="bootstrap-msg">Loading web interface...</div>
|
||||
</div>
|
||||
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
|
||||
<script>
|
||||
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
|
||||
function scriptError(err) {
|
||||
console.error(err);
|
||||
let bootstrapMsg = document.getElementById("bootstrap-msg");
|
||||
if (bootstrapMsg) {
|
||||
let errorMsg = err;
|
||||
if (err?.message) {
|
||||
errorMsg = `${err?.constructor?.name ?? "Error"}: ${err.message}`;
|
||||
}
|
||||
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
|
||||
}
|
||||
}
|
||||
const bundleScript = document.getElementById("script-bundle");
|
||||
bundleScript.addEventListener("error", () => scriptError("Main bundle failed to load."));
|
||||
window.addEventListener("error", event => scriptError(event.error));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
declare function scriptError(err: any): void;
|
||||
import("@clusterio/web_ui")
|
||||
.then(webUi => webUi.bootstrap())
|
||||
.catch(scriptError)
|
||||
;
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
contentBase: "./dist/web",
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "controller",
|
||||
shared: {
|
||||
"@clusterio/lib": { singleton: true },
|
||||
"@clusterio/web_ui": { singleton: true },
|
||||
"antd": { singleton: true },
|
||||
"react": { singleton: true },
|
||||
"react-dom": { singleton: true },
|
||||
"react-router": { singleton: true },
|
||||
"react-router-dom": { singleton: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Clusterio installer
|
||||
|
||||
Installer for Clusterio.
|
||||
|
||||
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
|
||||
@@ -0,0 +1,861 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs-extra");
|
||||
const inquirer = require("inquirer");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const phin = require("phin");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
const availablePlugins = [
|
||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
|
||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
||||
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
||||
|
||||
// Comunity plugins
|
||||
{ name: "Discord Bridge", value: "@hornwitser/discord_bridge" },
|
||||
{ name: "Server Select", value: "@hornwitser/server_select" },
|
||||
];
|
||||
|
||||
const factorioLocations = {
|
||||
win32: [
|
||||
"C:\\Program Files\\Factorio",
|
||||
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio",
|
||||
],
|
||||
darwin: [
|
||||
"/Applications/factorio.app/Contents",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
class LineSplitter extends stream.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (this._partial) {
|
||||
chunk = Buffer.concat([this._partial, chunk]);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
while (chunk.length) {
|
||||
let end = chunk.indexOf("\n");
|
||||
if (end === -1) {
|
||||
this._partial = chunk;
|
||||
break;
|
||||
}
|
||||
|
||||
let next = end + 1;
|
||||
// Eat carriage return as well if present
|
||||
if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
let line = chunk.slice(0, end);
|
||||
chunk = chunk.slice(next);
|
||||
this.push(line);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
if (this._partial) {
|
||||
this.push(this._partial);
|
||||
this._partial = null;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
class InstallError extends Error { }
|
||||
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
stdout.on("data", line => { logger.verbose(line.toString()); });
|
||||
child.stdout.pipe(stdout);
|
||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||
stderr.on("data", line => { logger.verbose(`err: ${line.toString()}`); });
|
||||
child.stderr.pipe(stderr);
|
||||
});
|
||||
}
|
||||
|
||||
async function execController(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execHost(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execCtl(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
|
||||
function validateHostToken(token) {
|
||||
let parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
|
||||
} catch (err) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
|
||||
if (parsed.aud !== "host" || !Number.isInteger(parsed.host)) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateInstallDir() {
|
||||
let entries = new Set(await fs.readdir("."));
|
||||
if (entries.size) {
|
||||
if (!entries.has("package.json")) {
|
||||
throw new InstallError("Refusing to install to non-empty directory");
|
||||
}
|
||||
|
||||
let packageData;
|
||||
try {
|
||||
packageData = JSON.parse(await fs.readFile("package.json"));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Failed to read package.json: ${err.message}`);
|
||||
}
|
||||
if (packageData.name !== "clusterio-install" || !packageData.private) {
|
||||
throw new InstallError("Refusing to run on non-clusterio installation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateNotRoot(args) {
|
||||
if (args.allowInstallAsRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (os.userInfo().uid === 0) {
|
||||
throw new InstallError(
|
||||
"Refusing to install as root. Create a separate user account for clusterio " +
|
||||
"and then use su/sudo to switch to it before invoking the installer."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateRename(args) {
|
||||
function rename(string) {
|
||||
return string.replace(/master/g, "controller").replace(/slave/g, "host");
|
||||
}
|
||||
|
||||
function renameConfig(config) {
|
||||
for (let group of config["groups"]) {
|
||||
group["name"] = rename(group["name"]);
|
||||
group["fields"] = Object.fromEntries(
|
||||
Object.entries(group["fields"]).map(([k, v]) => [rename(k), v])
|
||||
);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function migrateConfig(source, destination) {
|
||||
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
|
||||
await safeOutputFile(destination, JSON.stringify(
|
||||
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
||||
));
|
||||
logger.info(`Migrated ${source} to ${destination}`);
|
||||
}
|
||||
}
|
||||
|
||||
await migrateConfig("config-master.json", "config-controller.json");
|
||||
await migrateConfig("config-slave.json", "config-host.json");
|
||||
|
||||
let instancesFile = path.join("database", "instances.json");
|
||||
if (await fs.pathExists(instancesFile)) {
|
||||
await safeOutputFile(instancesFile, JSON.stringify(
|
||||
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
|
||||
));
|
||||
logger.info(`Migrated ${instancesFile}`);
|
||||
}
|
||||
|
||||
let usersFile = path.join("database", "users.json");
|
||||
if (await fs.pathExists(usersFile)) {
|
||||
let users = JSON.parse(await fs.readFile(usersFile));
|
||||
for (let role of users["roles"]) {
|
||||
role["permissions"] = role["permissions"].map(rename);
|
||||
}
|
||||
await safeOutputFile(usersFile, JSON.stringify(users, null, "\t"));
|
||||
logger.info(`Migrated ${usersFile}`);
|
||||
}
|
||||
|
||||
function renameLogLine(info) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([k, v]) => [rename(k), v])
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateLog(inputFile, outputFile) {
|
||||
let lineStream = new LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = fs.createReadStream(inputFile);
|
||||
fileStream.pipe(lineStream);
|
||||
let lines = [];
|
||||
|
||||
for await (let inputLine of lineStream) {
|
||||
try {
|
||||
lines.push(Buffer.from(JSON.stringify(renameLogLine(JSON.parse(inputLine)))));
|
||||
lines.push(Buffer.from("\n"));
|
||||
} catch (err) {
|
||||
if (!(err instanceof SyntaxError)) {
|
||||
throw err;
|
||||
}
|
||||
logger.warn(`Invalid log line: ${inputLine.toString()}`);
|
||||
lines.push(inputLine);
|
||||
}
|
||||
}
|
||||
safeOutputFile(outputFile, Buffer.concat(lines));
|
||||
}
|
||||
|
||||
async function migrateLogsDir(inputLogs, outputLogs) {
|
||||
if (await fs.pathExists(inputLogs)) {
|
||||
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
|
||||
for (let logFile of await fs.readdir(inputLogs)) {
|
||||
if (!logFile.endsWith(".log")) {
|
||||
continue;
|
||||
}
|
||||
let inputFile = path.join(inputLogs, logFile);
|
||||
let outputFile = path.join(outputLogs, rename(logFile));
|
||||
if (!await fs.pathExists(outputFile)) {
|
||||
await migrateLog(inputFile, outputFile);
|
||||
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
|
||||
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
|
||||
if (
|
||||
await fs.pathExists(path.join("logs", "cluster"))
|
||||
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
|
||||
) {
|
||||
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
|
||||
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
|
||||
}
|
||||
|
||||
if (
|
||||
await fs.pathExists("sharedMods")
|
||||
&& !await fs.pathExists("mods")
|
||||
) {
|
||||
logger.info("Moving sharedMods/ to mods/");
|
||||
await fs.rename("sharedMods", "mods");
|
||||
}
|
||||
|
||||
if (!args.dev) {
|
||||
let pkg = JSON.parse(await fs.readFile("package.json"));
|
||||
if (pkg.dependencies) {
|
||||
let convert = ["@clusterio/master", "@clusterio/slave"];
|
||||
let uninstall = convert.filter(c => pkg.dependencies[c] !== undefined);
|
||||
let install = uninstall.map(rename);
|
||||
if (uninstall.length) {
|
||||
logger.info(`Replacing ${uninstall.join(" and ")}`);
|
||||
await execFile(`npm${scriptExt}`, ["install", ...install]);
|
||||
await execFile(`npm${scriptExt}`, ["uninstall", ...uninstall]);
|
||||
}
|
||||
}
|
||||
logger.info("Updating packages");
|
||||
await execFile(`npm${scriptExt}`, ["update"]);
|
||||
}
|
||||
|
||||
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
|
||||
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
|
||||
if (hasRunMaster || hasRunSlave) {
|
||||
logger.info("Writing run scripts");
|
||||
let mode = "standalone";
|
||||
if (!hasRunSlave) { mode = "controller"; }
|
||||
if (!hasRunMaster) { mode = "host"; }
|
||||
await writeScripts(mode);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Migration complete, you may now delete the following left over files and directories (if present):" +
|
||||
"\n- config-master.json" +
|
||||
"\n- config-slave.json" +
|
||||
"\n- logs/master" +
|
||||
"\n- logs/slave" +
|
||||
"\n- logs/cluster-prerename" +
|
||||
"\n- systemd/clusteriomaster.service" +
|
||||
"\n- systemd/clusterioslave.service" +
|
||||
"\n- run-master.sh / run-master.cmd" +
|
||||
"\n- run-slave.sh / run-slave.cmd"
|
||||
);
|
||||
}
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await fs.outputFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw new InstallError(`Failed to write package.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let components = [];
|
||||
if (["standalone", "controller"].includes(mode)) {
|
||||
components.push("@clusterio/controller");
|
||||
}
|
||||
if (["standalone", "host"].includes(mode)) {
|
||||
components.push("@clusterio/host");
|
||||
}
|
||||
if (mode === "ctl") {
|
||||
components.push("@clusterio/ctl");
|
||||
}
|
||||
|
||||
logger.info(`Please wait, installing ${mode}`);
|
||||
try {
|
||||
await execFile(`npm${scriptExt}`, ["install", ...components, ...plugins]);
|
||||
} catch (err) {
|
||||
throw new InstallError(`Failed to install: ${err.message}`);
|
||||
}
|
||||
|
||||
if (plugins.length) {
|
||||
logger.info("Setting up plugins");
|
||||
let pluginList;
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json")));
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
pluginList = new Map();
|
||||
} else {
|
||||
throw new InstallError(`Error loading plugin-list.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, "\t"));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function groupIdToName(gid) {
|
||||
try {
|
||||
let exec = util.promisify(child_process.exec);
|
||||
let { stdout } = await exec(`getent group ${gid}`);
|
||||
return stdout.split(":")[0];
|
||||
} catch (err) {
|
||||
logger.warn(`getent group ${gid} failed: ${err.message}`);
|
||||
return gid;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScripts(mode) {
|
||||
if (["standalone", "controller"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await safeOutputFile(
|
||||
"run-controller.cmd",
|
||||
`\
|
||||
@echo off
|
||||
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
|
||||
:restart
|
||||
call .\\node_modules\\.bin\\clusteriocontroller.cmd run --can-restart
|
||||
if %errorlevel% equ 0 exit /b
|
||||
if %errorlevel% equ 8 exit /b
|
||||
goto restart
|
||||
`
|
||||
);
|
||||
} else {
|
||||
await safeOutputFile(
|
||||
"run-controller.sh",
|
||||
`\
|
||||
#!/bin/bash
|
||||
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
|
||||
while true; do
|
||||
./node_modules/.bin/clusteriocontroller run --can-restart
|
||||
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
|
||||
done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriocontroller.service",
|
||||
`\
|
||||
[Unit]
|
||||
Description=Clusterio Controller
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
Environment=NODE_OPTIONS=--enable-source-maps
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusteriocontroller run --log-level=warn --can-restart
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=8
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (["standalone", "host"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await safeOutputFile(
|
||||
"run-host.cmd",
|
||||
`\
|
||||
@echo off
|
||||
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
|
||||
:restart
|
||||
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
|
||||
if %errorlevel% equ 0 exit /b
|
||||
if %errorlevel% equ 8 exit /b
|
||||
goto restart
|
||||
`
|
||||
);
|
||||
} else {
|
||||
await safeOutputFile(
|
||||
"run-host.sh",
|
||||
`\
|
||||
#!/bin/bash
|
||||
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
|
||||
while true; do
|
||||
./node_modules/.bin/clusteriohost run --can-restart
|
||||
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
|
||||
done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriohost.service",
|
||||
`\
|
||||
[Unit]
|
||||
Description=Clusterio Host
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
Environment=NODE_OPTIONS=--enable-source-maps
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=8
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
async function inquirerMissingArgs(args) {
|
||||
let answers = {};
|
||||
if (args.mode) { answers.mode = args.mode; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "mode",
|
||||
message: "Operating mode to install",
|
||||
default: "standalone",
|
||||
choices: [
|
||||
{ name: "Standalone (install both controller and host on this computer)", value: "standalone" },
|
||||
{ name: "Controller only", value: "controller" },
|
||||
{ name: "Host only", value: "host" },
|
||||
{ name: "Ctl only", value: "ctl" },
|
||||
{ name: "Plugins only", value: "plugins" },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
if (args.admin) { answers.admin = args.admin; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "admin",
|
||||
message: "Admin account name",
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
if (args.hostName) { answers.hostName = args.hostName; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "hostName",
|
||||
message: "Name of host",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (["host", "ctl"].includes(answers.mode)) {
|
||||
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "controllerUrl",
|
||||
message: "Controller URL",
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.controllerToken) { answers.controllerToken = args.controllerToken; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "controllerToken",
|
||||
message: "Controller authentication Token",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
validateHostToken(answers.controllerToken);
|
||||
}
|
||||
|
||||
if (["standalone", "host"].includes(answers.mode)) {
|
||||
let myIp = "localhost";
|
||||
if (args.publicAddress) {
|
||||
answers.publicAddress = args.publicAddress;
|
||||
} else {
|
||||
try {
|
||||
let result = await phin("https://api.ipify.org/");
|
||||
myIp = result.body.toString();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "publicAddress",
|
||||
message: "Public DNS/IP address of this server",
|
||||
default: myIp,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.factorioDir) { answers.factorioDir = args.factorioDir; }
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
let foundLocations = [];
|
||||
for (let location of locations) {
|
||||
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
foundLocations.push(location);
|
||||
}
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "downloadHeadless",
|
||||
message: "(Linux only) Automatically download latest factorio release?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (!answers.factorioDir && answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
choices: [
|
||||
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
|
||||
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
|
||||
{ name: "Provide path manually", value: null },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (answers.factorioDir === null) {
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
askAnswered: true,
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.ensureDir("factorio");
|
||||
}
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
answers.plugins = [];
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "plugins",
|
||||
message: "Plugins to install",
|
||||
choices: availablePlugins,
|
||||
pageSize: 20,
|
||||
},
|
||||
], answers);
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function downloadLinuxServer() {
|
||||
let res = await phin("https://factorio.com/get-download/stable/headless/linux64");
|
||||
|
||||
const url = new URL(res.headers.location);
|
||||
// get the filename of the latest factorio archive from redirected url
|
||||
const filename = path.posix.basename(url.pathname);
|
||||
const version = filename.match(/(?<=factorio_headless_x64_).*(?=\.tar\.xz)/)[0];
|
||||
|
||||
const tmpDir = "temp/create-temp/";
|
||||
const archivePath = tmpDir + filename;
|
||||
const tmpArchivePath = `${archivePath}.tmp`;
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await fs.pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.ensureDir(tmpDir);
|
||||
|
||||
// follow the redirect
|
||||
res = await phin({
|
||||
url: url.href,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
res.pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.ensureDir(tmpFactorioDir);
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("error executing command- do you have 'xz-utils' installed?");
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let args = yargs
|
||||
.option("log-level", {
|
||||
nargs: 1, describe: "Log level to print to stdout", default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)), type: "string",
|
||||
})
|
||||
.option("dev", {
|
||||
nargs: 0, describe: "Initialize development repository", hidden: true, default: false, type: "boolean",
|
||||
})
|
||||
.option("mode", {
|
||||
nargs: 1, describe: "Operating mode to install",
|
||||
choices: ["standalone", "controller", "host", "ctl", "plugins"],
|
||||
})
|
||||
.option("migrate-rename", {
|
||||
nargs: 0, describe: "Migrate from before slave/master rename", default: false, type: "boolean",
|
||||
})
|
||||
.option("admin", {
|
||||
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
.option("controller-url", {
|
||||
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
|
||||
})
|
||||
.option("controller-token", {
|
||||
nargs: 1, describe: "Controller authentication token [host/ctl]", type: "string",
|
||||
})
|
||||
.option("public-address", {
|
||||
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/host]", type: "string",
|
||||
})
|
||||
.option("factorio-dir", {
|
||||
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
})
|
||||
.strict()
|
||||
;
|
||||
|
||||
if (process.platform === "linux") {
|
||||
args = args
|
||||
.option("allow-install-as-root", {
|
||||
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
||||
})
|
||||
.option("download-headless", {
|
||||
nargs: 0,
|
||||
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
|
||||
"Can be set to false using --no-download-headless.",
|
||||
type: "boolean",
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
args = args.argv;
|
||||
|
||||
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
|
||||
dev = args.dev;
|
||||
|
||||
if (!dev) {
|
||||
await validateInstallDir();
|
||||
await validateNotRoot(args);
|
||||
}
|
||||
|
||||
if (args.migrateRename) {
|
||||
await migrateRename(args);
|
||||
return;
|
||||
}
|
||||
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
}
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
|
||||
let adminToken = null;
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
logger.info("Setting up controller");
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
||||
adminToken = result.stdout.split("\n").slice(-2)[0];
|
||||
}
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
logger.info("Setting up host");
|
||||
await execHost(["config", "set", "host.name", "local"]);
|
||||
|
||||
let result = await execHost(["config", "show", "host.id"]);
|
||||
let hostId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
|
||||
|
||||
result = await execController(["bootstrap", "generate-host-token", hostId]);
|
||||
let hostToken = result.stdout.split("\n").slice(-2)[0];
|
||||
|
||||
await execHost(["config", "set", "host.controller_token", hostToken]);
|
||||
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
||||
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
logger.info("Setting up host");
|
||||
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
|
||||
await execHost(["config", "set", "host.id", hostId]);
|
||||
await execHost(["config", "set", "host.name", answers.hostName]);
|
||||
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
|
||||
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
|
||||
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
||||
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
||||
}
|
||||
|
||||
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
logger.info("Writing run scripts");
|
||||
await writeScripts(answers.mode);
|
||||
}
|
||||
|
||||
if (answers.mode === "ctl") {
|
||||
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
|
||||
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log(`Successfully installed ${answers.mode}`);
|
||||
if (adminToken) {
|
||||
console.log(`Admin authentication token: ${adminToken}`);
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
main().catch(err => {
|
||||
if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured installing clusterio, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const chalk = require("chalk");
|
||||
|
||||
const levels = Object.freeze({
|
||||
fatal: 0,
|
||||
error: 1,
|
||||
warn: 2,
|
||||
audit: 3,
|
||||
info: 4,
|
||||
verbose: 6,
|
||||
});
|
||||
|
||||
const colors = {
|
||||
fatal: "inverse red",
|
||||
error: "bold redBright",
|
||||
warn: "bold yellowBright",
|
||||
audit: "bold greenBright",
|
||||
info: "bold blueBright",
|
||||
verbose: "bold grey",
|
||||
};
|
||||
|
||||
let maxLogLevel = levels.info;
|
||||
const logger = {};
|
||||
for (let [name, level] of Object.entries(levels)) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
logger[name] = message => {
|
||||
if (level > maxLogLevel) {
|
||||
return;
|
||||
}
|
||||
let style = chalk;
|
||||
for (let part of colors[name].split(" ")) {
|
||||
style = style[part];
|
||||
}
|
||||
if (["fatal", "error"].includes(name)) {
|
||||
console.error(`${style(name)} ${message}`);
|
||||
} else if (["warn"].includes(name)) {
|
||||
console.warn(`${style(name)} ${message}`);
|
||||
} else {
|
||||
console.log(`${style(name)} ${message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
colors,
|
||||
levels,
|
||||
setLogLevel: (level) => { maxLogLevel = level; },
|
||||
logger,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@clusterio/create",
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio",
|
||||
"clusterio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"fs-extra": "^11.1.1",
|
||||
"inquirer": "^8.2.4",
|
||||
"phin": "^3.7.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Clusterio Command Line Interface
|
||||
|
||||
|
||||
Provides a command line interface for managing a Clusterio cluster.
|
||||
|
||||
Note that this document only describes the options and commands for managing clusterioctl itself, see [Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
npx clusterioctl <command>
|
||||
|
||||
Common options:
|
||||
|
||||
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to ctl.
|
||||
Defaults to `plugin-list.json` and will be created if it does not exist.
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for ctl.
|
||||
Defaults to `config-control.json` and will be created if it does not exist.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
Configure plugins available to be loaded by ctl.
|
||||
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
|
||||
Remove a plugin by its name.
|
||||
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
|
||||
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
|
||||
|
||||
For example, uninstalling the Subspace Storage plugin:
|
||||
|
||||
npx clusterioctl plugin remove subspace_storage
|
||||
npm uninstall @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin list`
|
||||
|
||||
Lists the plugins set up to be available by name followed by path.
|
||||
|
||||
|
||||
### `config-control`
|
||||
|
||||
Manage the ctl configuration.
|
||||
This allows setting the url and token to the controller to manage.
|
||||
|
||||
|
||||
#### `config-control set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
If value is not provided the entry is set to null.
|
||||
If the config entry is of type object the value must be a valid JSON serialization of an object.
|
||||
|
||||
See docs/configuration.md in the main repositiory for the available configuration.
|
||||
|
||||
|
||||
#### `config-control show <config-entry>`
|
||||
|
||||
Shows the value for a single config entry.
|
||||
|
||||
|
||||
#### `config-control list`
|
||||
|
||||
Lists up all configuration entries with their currently configured values.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
[Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
|
||||
|
||||
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
|
||||
@@ -1,784 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Command line interface for controlling a Clusterio cluster
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
const version = require("./package").version;
|
||||
const asTable = require("as-table").configure({ delimiter: " | " });
|
||||
const chalk = require("chalk");
|
||||
const events = require("events");
|
||||
const path = require("path");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libCommand = require("@clusterio/lib/command");
|
||||
const libSharedCommands = require("@clusterio/lib/shared_commands");
|
||||
|
||||
|
||||
/**
|
||||
* Format a parsed Factorio output message with colors
|
||||
*
|
||||
* Formats a parsed Factorio output from lib/factorio into a readable
|
||||
* colorized output using terminal escape codes that can be printed.
|
||||
*
|
||||
* @param {Object} output - Factorio server output.
|
||||
* @returns {string} terminal colorized message.
|
||||
* @private
|
||||
*/
|
||||
function formatOutputColored(output) {
|
||||
let time = "";
|
||||
if (output.format === "seconds") {
|
||||
time = chalk.yellow(output.time.padStart(8)) + " ";
|
||||
} else if (output.format === "date") {
|
||||
time = chalk.yellow(output.time) + " ";
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (output.type === "log") {
|
||||
let level = output.level;
|
||||
if (level === "Info") {
|
||||
level = chalk.bold.blueBright(level);
|
||||
} else if (output.level === "Warning") {
|
||||
level = chalk.bold.yellowBright(level);
|
||||
} else if (output.level === "Error") {
|
||||
level = chalk.bold.redBright(level);
|
||||
}
|
||||
|
||||
info = level + " " + chalk.gray(output.file) + ": ";
|
||||
|
||||
} else if (output.type === "action") {
|
||||
info = "[" + chalk.yellow(output.action) + "] ";
|
||||
}
|
||||
|
||||
return time + info + output.message;
|
||||
}
|
||||
|
||||
const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" });
|
||||
slaveCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List slaves connected to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listSlaves.send(control);
|
||||
console.log(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
slaveCommands.add(new libCommand.Command({
|
||||
definition: ["generate-token", "Generate token for a slave", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await link.messages.generateSlaveToken.send(control, { slave_id: args.id });
|
||||
console.log(response.token);
|
||||
},
|
||||
}));
|
||||
|
||||
slaveCommands.add(new libCommand.Command({
|
||||
definition: ["create-config", "Create slave config", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null });
|
||||
yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null });
|
||||
yargs.option("generate-token", {
|
||||
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
|
||||
});
|
||||
yargs.option("output", {
|
||||
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-slave.json",
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.createSlaveConfig.send(control, {
|
||||
id: args.id, name: args.name, generate_token: args.generateToken,
|
||||
});
|
||||
|
||||
let content = JSON.stringify(response.serialized_config, null, 4);
|
||||
if (args.output === "-") {
|
||||
console.log(content);
|
||||
} else {
|
||||
console.log(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.outputFile(args.output, content, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new libErrors.CommandError(`File ${args.output} already exists`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const instanceCommands = new libCommand.CommandTree({
|
||||
name: "instance", alias: ["i"], description: "Instance management",
|
||||
});
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List instances known to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listInstances.send(control);
|
||||
console.log(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["create <name>", "Create an instance", (yargs) => {
|
||||
// XXX TODO: set any specific options?
|
||||
yargs.positional("name", { describe: "Instance name", type: "string" });
|
||||
yargs.options({
|
||||
"id": { type: "number", nargs: 1, describe: "Instance id" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceConfig = new libConfig.InstanceConfig();
|
||||
await instanceConfig.init();
|
||||
if (args.id) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
let serialized_config = instanceConfig.serialize();
|
||||
let response = await libLink.messages.createInstance.send(control, { serialized_config });
|
||||
},
|
||||
}));
|
||||
|
||||
const instanceConfigCommands = new libCommand.CommandTree({
|
||||
name: "config", alias: ["c"], description: "Instance config management",
|
||||
});
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
|
||||
|
||||
for (let group of response.serialized_config.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
console.log(`${group.name}.${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["set <instance> <field> <value>", "Set field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("value", { describe: "Value to set", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceConfigField.send(control, {
|
||||
instance_id: instanceId,
|
||||
field: args.field,
|
||||
value: args.value,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new libCommand.Command({
|
||||
definition: ["set-prop <instance> <field> <prop> <value>", "Set property of field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property to set", type: "string" });
|
||||
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceConfigProp.send(control, {
|
||||
instance_id: instanceId,
|
||||
field: args.field,
|
||||
prop: args.prop,
|
||||
value: JSON.parse(args.value),
|
||||
});
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceConfigCommands);
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["assign <instance> <slave>", "Assign instance to a slave", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
|
||||
yargs.positional("slave", { describe: "Slave to assign to", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let slaveId = await libCommand.resolveSlave(control, args.slave);
|
||||
await libLink.messages.assignInstanceCommand.send(control, {
|
||||
instance_id: instanceId,
|
||||
slave_id: slaveId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["create-save <instance>", "Create a new save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.createSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.exportData.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["start <instance>", "Start instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.startInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
save: args.save || null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
|
||||
yargs.options({
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.loadScenario.send(control, {
|
||||
instance_id: instanceId,
|
||||
scenario: args.scenario || null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["stop <instance>", "Stop instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await libLink.messages.stopInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["delete <instance>", "Delete instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.deleteInstance.send(control, {
|
||||
instance_id: await libCommand.resolveInstance(control, args.instance),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
|
||||
yargs.positional("command", { describe: "command to send", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.sendRcon.send(control, {
|
||||
instance_id: await libCommand.resolveInstance(control, args.instance),
|
||||
command: args.command,
|
||||
});
|
||||
|
||||
// Factorio includes a newline in it's response output.
|
||||
process.stdout.write(response.result);
|
||||
},
|
||||
}));
|
||||
|
||||
const permissionCommands = new libCommand.CommandTree({ name: "permission", description: "Permission inspection" });
|
||||
permissionCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List permissions in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listPermissions.send(control);
|
||||
console.log(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const roleCommands = new libCommand.CommandTree({ name: "role", description: "Role management" });
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List roles in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listRoles.send(control);
|
||||
console.log(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: ["create <name>", "Create a new role", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of role to create", type: "string" });
|
||||
yargs.options({
|
||||
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
|
||||
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.createRole.send(control, {
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
permissions: args.permissions,
|
||||
});
|
||||
console.log(`Created role ID ${response.id}`);
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: ["edit <role>", "Edit existing role", (yargs) => {
|
||||
yargs.positional("role", { describe: "Role to edit", type: "string" });
|
||||
yargs.options({
|
||||
"name": { describe: "New name for role", nargs: 1, type: "string" },
|
||||
"description": { describe: "New description for role", nargs: 1, type: "string" },
|
||||
"permissions": { describe: "New permissions for role", array: true, type: "string" },
|
||||
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let role = await libCommand.retrieveRole(control, args.role);
|
||||
|
||||
if (args.name !== undefined) {
|
||||
role.name = args.name;
|
||||
}
|
||||
if (args.description !== undefined) {
|
||||
role.description = args.description;
|
||||
}
|
||||
if (args.permissions !== undefined) {
|
||||
role.permissions = args.permissions;
|
||||
}
|
||||
await libLink.messages.updateRole.send(control, role);
|
||||
|
||||
if (args.grantDefault) {
|
||||
await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new libCommand.Command({
|
||||
definition: ["delete <role>", "Delete role", (yargs) => {
|
||||
yargs.positional("role", { describe: "Role to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let role = await libCommand.retrieveRole(control, args.role);
|
||||
await libLink.messages.deleteRole.send(control, { id: role.id });
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const userCommands = new libCommand.CommandTree({ name: "user", alias: ["u"], description: "User management" });
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: [["list", "l"], "List user in the cluster"],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listUsers.send(control);
|
||||
console.log(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["create <name>", "Create a user", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of user to create", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.createUser.send(control, { name: args.name });
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
|
||||
yargs.options({
|
||||
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.setUserAdmin.send(control, {
|
||||
name: args.user, create: args.create, admin: !args.revoke,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
|
||||
yargs.options({
|
||||
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.setUserWhitelisted.send(control, {
|
||||
name: args.user, create: args.create, whitelisted: !args.remove,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
|
||||
yargs.options({
|
||||
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
|
||||
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.setUserBanned.send(control, {
|
||||
name: args.user, create: args.create, banned: !args.pardon, reason: args.reason,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
|
||||
yargs.positional("roles", { describe: "roles to assign", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await libLink.messages.listRoles.send(control);
|
||||
|
||||
let resolvedRoles = [];
|
||||
for (let roleName of args.roles) {
|
||||
if (/^-?\d+$/.test(roleName)) {
|
||||
let roleId = parseInt(roleName, 10);
|
||||
resolvedRoles.push(roleId);
|
||||
|
||||
} else {
|
||||
let found = false;
|
||||
for (let role of response.list) {
|
||||
if (role.name === roleName) {
|
||||
resolvedRoles.push(role.id);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new libErrors.CommandError(`No role named ${roleName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles });
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new libCommand.Command({
|
||||
definition: ["delete <user>", "Delete user", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.deleteUser.send(control, { name: args.user });
|
||||
},
|
||||
}));
|
||||
|
||||
const debugCommands = new libCommand.CommandTree({ name: "debug", description: "Debugging utilities" });
|
||||
debugCommands.add(new libCommand.Command({
|
||||
definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }],
|
||||
handler: async function(args, control) {
|
||||
await libLink.messages.debugDumpWs.send(control);
|
||||
return new Promise(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to master server
|
||||
* @private
|
||||
*/
|
||||
class ControlConnector extends libLink.WebSocketClientConnector {
|
||||
constructor(url, reconnectDelay, token) {
|
||||
super(url, reconnectDelay);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
register() {
|
||||
console.log("SOCKET | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this._token,
|
||||
agent: "clusterioctl",
|
||||
version: version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the master server over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
class Control extends libLink.Link {
|
||||
|
||||
constructor(connector, controlPlugins) {
|
||||
super("control", "master", connector);
|
||||
libLink.attachAllMessages(this);
|
||||
|
||||
/**
|
||||
* Mapping of plugin names to their instance for loaded plugins.
|
||||
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
|
||||
*/
|
||||
this.plugins = controlPlugins;
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
async instanceOutputEventHandler(message) {
|
||||
let { instance_id, output } = message.data;
|
||||
console.log(formatOutputColored(output));
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
console.log("WS", message.data.direction, message.data.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.connector.setTimeout(30);
|
||||
|
||||
try {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await this.connector.close(1001, "Control Quit");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
default: "config-control.json",
|
||||
defaultDescription: "auto",
|
||||
type: "string",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
|
||||
.command("control-config", "Manage Control config", libConfig.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
.help(false) // Disable help to avoid triggering it on the first parse.
|
||||
;
|
||||
|
||||
// Parse the args first to get the configured plugin list.
|
||||
let args = yargs.argv;
|
||||
|
||||
console.log(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await libSharedCommands.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add all cluster management commands including ones from plugins
|
||||
const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
|
||||
rootCommands.add(slaveCommands);
|
||||
rootCommands.add(instanceCommands);
|
||||
rootCommands.add(permissionCommands);
|
||||
rootCommands.add(roleCommands);
|
||||
rootCommands.add(userCommands);
|
||||
rootCommands.add(debugCommands);
|
||||
|
||||
console.log("Loading Plugin info");
|
||||
let pluginInfos = await libPlugin.loadPluginInfos(pluginList);
|
||||
libConfig.registerPluginConfigGroups(pluginInfos);
|
||||
libConfig.finalizeConfigs();
|
||||
|
||||
let controlPlugins = new Map();
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (!pluginInfo.controlEntrypoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let { ControlPlugin } = require(path.posix.join(pluginInfo.requirePath, pluginInfo.controlEntrypoint));
|
||||
let controlPlugin = new ControlPlugin(pluginInfo);
|
||||
controlPlugins.set(pluginInfo.name, controlPlugin);
|
||||
await controlPlugin.init();
|
||||
await controlPlugin.addCommands(rootCommands);
|
||||
}
|
||||
|
||||
for (let [name, command] of rootCommands.subCommands) {
|
||||
if (name === command.name) {
|
||||
command.register(yargs);
|
||||
}
|
||||
}
|
||||
|
||||
// Reparse after commands have been added with help and strict checking.
|
||||
args = yargs
|
||||
.help()
|
||||
.strict()
|
||||
.argv
|
||||
;
|
||||
|
||||
console.log(`Loading config from ${args.config}`);
|
||||
let controlConfig = new libConfig.ControlConfig();
|
||||
try {
|
||||
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.log("Config not found, initializing new config");
|
||||
await controlConfig.init();
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (args._.length === 0) {
|
||||
yargs.showHelp();
|
||||
yargs.exit();
|
||||
}
|
||||
|
||||
// Handle the control-config command before trying to connect.
|
||||
if (args._[0] === "control-config") {
|
||||
await libConfig.handleConfigCommand(args, controlConfig, args.config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which command is being executed.
|
||||
let commandPath = [...args._];
|
||||
let targetCommand = rootCommands;
|
||||
while (commandPath.length && targetCommand instanceof libCommand.CommandTree) {
|
||||
targetCommand = targetCommand.get(commandPath.shift());
|
||||
}
|
||||
|
||||
// The remaining commands require connecting to the master server.
|
||||
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
|
||||
console.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let controlConnector = new ControlConnector(
|
||||
controlConfig.get("control.master_url"),
|
||||
controlConfig.get("control.reconnect_delay"),
|
||||
controlConfig.get("control.master_token")
|
||||
);
|
||||
let control = new Control(controlConnector, controlPlugins);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch(err) {
|
||||
if (err instanceof libErrors.AuthenticationFailed) {
|
||||
throw new libErrors.StartupError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
let keepOpen = Boolean(args.keepOpen);
|
||||
try {
|
||||
await targetCommand.run(args, control);
|
||||
|
||||
} catch (err) {
|
||||
keepOpen = false;
|
||||
if (err instanceof libErrors.CommandError) {
|
||||
console.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof libErrors.RequestError) {
|
||||
console.error(`Error sending request: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (!keepOpen) {
|
||||
await control.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Control,
|
||||
|
||||
// for testing only
|
||||
_formatOutputColored: formatOutputColored,
|
||||
};
|
||||
|
||||
|
||||
if (module === require.main) {
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startControl().catch(err => {
|
||||
if (!(err instanceof libErrors.StartupError)) {
|
||||
console.error(`
|
||||
+----------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+----------------------------------------------------------------+`
|
||||
);
|
||||
} else {
|
||||
console.error(`
|
||||
+---------------------------------+
|
||||
| Unable to to start clusterioctl |
|
||||
+---------------------------------+`
|
||||
);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Command line interface for controlling a Clusterio cluster
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import yargs from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import * as commands from "./src/commands";
|
||||
import BaseCtlPlugin from "./src/BaseCtlPlugin";
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to controller
|
||||
* @private
|
||||
*/
|
||||
class ControlConnector extends lib.WebSocketClientConnector {
|
||||
private _token: string;
|
||||
|
||||
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
|
||||
super(url, maxReconnectDelay, tlsCa);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
register() {
|
||||
logger.verbose("Connector | registering control");
|
||||
this.sendHandshake(
|
||||
new lib.MessageRegisterControl(
|
||||
new lib.RegisterControlData(
|
||||
this._token,
|
||||
version,
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the controller over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
export class Control extends lib.Link {
|
||||
/** Control config used for connecting to the controller. */
|
||||
config: lib.ControlConfig;
|
||||
/** Certificate authority used to validate TLS connections to the controller. */
|
||||
tlsCa?: string;
|
||||
/** Mapping of plugin names to their instance for loaded plugins. */
|
||||
plugins: Map<string, BaseCtlPlugin>;
|
||||
/** Keep the control connection alive after the command completes. */
|
||||
keepOpen = false;
|
||||
|
||||
constructor(
|
||||
connector: ControlConnector,
|
||||
controlConfig: lib.ControlConfig,
|
||||
tlsCa: string | undefined,
|
||||
ctlPlugins: Map<string, BaseCtlPlugin>
|
||||
) {
|
||||
super(connector);
|
||||
this.config = controlConfig;
|
||||
this.tlsCa = tlsCa;
|
||||
this.plugins = ctlPlugins;
|
||||
|
||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
|
||||
}
|
||||
|
||||
async setLogSubscriptions({
|
||||
all = false,
|
||||
controller = false,
|
||||
hostIds = [] as number[],
|
||||
instanceIds = [] as number[],
|
||||
maxLevel = undefined as keyof typeof levels | undefined,
|
||||
}) {
|
||||
await this.send(
|
||||
new lib.LogSetSubscriptionsRequest(
|
||||
all, controller, hostIds, instanceIds, maxLevel,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async handleLogMessageEvent(event: lib.LogMessageEvent) {
|
||||
logger.log(event.info as any);
|
||||
}
|
||||
|
||||
async handleDebugWsMessageEvent(event: lib.DebugWsMessageEvent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("WS", event.direction, event.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try {
|
||||
await (this.connector as ControlConnector).disconnect();
|
||||
} catch (err) {
|
||||
if (!(err instanceof lib.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlugins(pluginList: Map<string, string>) {
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
|
||||
let ctlPlugins = new Map<string, BaseCtlPlugin>();
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (!pluginInfo.ctlEntrypoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let CtlPlugin = await lib.loadPluginClass(
|
||||
pluginInfo.name,
|
||||
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
|
||||
"CtlPlugin",
|
||||
BaseCtlPlugin,
|
||||
);
|
||||
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
|
||||
ctlPlugins.set(pluginInfo.name, ctlPlugin);
|
||||
await ctlPlugin.init();
|
||||
}
|
||||
return ctlPlugins;
|
||||
}
|
||||
|
||||
interface CtlArguments {
|
||||
[index: string]: unknown;
|
||||
$0: string,
|
||||
_: (string | number)[],
|
||||
logLevel: keyof typeof levels,
|
||||
config: string,
|
||||
pluginList: string,
|
||||
}
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stderr",
|
||||
default: "server",
|
||||
choices: Object.keys(levels),
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
default: "config-control.json",
|
||||
defaultDescription: "auto",
|
||||
type: "string",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("control-config", "Manage Control config", lib.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
.help(false) // Disable help to avoid triggering it on the first parse.
|
||||
;
|
||||
|
||||
// Parse the args first to get the configured plugin list.
|
||||
// eslint-disable-next-line node/no-sync
|
||||
let args = yargs.parseSync() as CtlArguments;
|
||||
|
||||
// Log stream for the ctl session.
|
||||
logger.add(
|
||||
new ConsoleTransport({
|
||||
errorLevels: Object.keys(levels),
|
||||
level: args.logLevel,
|
||||
format: new lib.TerminalFormat(),
|
||||
})
|
||||
);
|
||||
lib.handleUnhandledErrors();
|
||||
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.verbose("Loading Plugins");
|
||||
let ctlPlugins = await loadPlugins(pluginList);
|
||||
|
||||
// Add all cluster management commands including ones from plugins
|
||||
let rootCommands = await commands.registerCommands(ctlPlugins, yargs);
|
||||
|
||||
// Reparse after commands have been added with help and strict checking.
|
||||
args = yargs
|
||||
.help()
|
||||
.strict()
|
||||
.parse() as CtlArguments
|
||||
;
|
||||
|
||||
logger.verbose(`Loading config from ${args.config}`);
|
||||
let controlConfig;
|
||||
try {
|
||||
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
|
||||
controlConfig = lib.ControlConfig.fromJSON(jsonConfig, "control");
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.verbose("Config not found, initializing new config");
|
||||
controlConfig = new lib.ControlConfig("control");
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args._.length === 0) {
|
||||
yargs.showHelp();
|
||||
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
}
|
||||
|
||||
// Handle the control-config command before trying to connect.
|
||||
if (args._[0] === "control-config") {
|
||||
await lib.handleConfigCommand(args, controlConfig, args.config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which command is being executed.
|
||||
let commandPath = [...args._] as string[];
|
||||
let targetCommand: lib.CommandTree | lib.Command = rootCommands;
|
||||
while (commandPath.length && targetCommand instanceof lib.CommandTree) {
|
||||
targetCommand = targetCommand.get(commandPath.shift()!)!;
|
||||
}
|
||||
assert(targetCommand instanceof lib.Command);
|
||||
|
||||
// The remaining commands require connecting to the controller.
|
||||
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
|
||||
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let tlsCa: string | undefined;
|
||||
let tlsCaPath = controlConfig.get("control.tls_ca");
|
||||
if (tlsCaPath) {
|
||||
tlsCa = await fs.readFile(tlsCaPath, "utf8");
|
||||
}
|
||||
|
||||
let controlConnector = new ControlConnector(
|
||||
controlConfig.get("control.controller_url")!,
|
||||
controlConfig.get("control.max_reconnect_delay"),
|
||||
tlsCa,
|
||||
controlConfig.get("control.controller_token")!,
|
||||
);
|
||||
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch (err) {
|
||||
if (err instanceof lib.AuthenticationFailed) {
|
||||
throw new lib.StartupError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await targetCommand.run(args, control);
|
||||
|
||||
} catch (err) {
|
||||
control.keepOpen = false;
|
||||
if (err instanceof lib.CommandError) {
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof lib.RequestError) {
|
||||
if (err.stack) {
|
||||
logger.error(`Error sending request:\n${err.stack}`);
|
||||
} else {
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (!control.keepOpen) {
|
||||
await control.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bootstrap() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startControl().catch(err => {
|
||||
if (err.errors) {
|
||||
logger.fatal(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
if (!(err instanceof lib.StartupError)) {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else {
|
||||
logger.error(`
|
||||
+---------------------------------+
|
||||
| Unable to to start clusterioctl |
|
||||
+---------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { bootstrap } from "./ctl";
|
||||
export * from "./ctl";
|
||||
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
{
|
||||
"name": "@clusterio/ctl",
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.0",
|
||||
"main": "ctl.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusterioctl": "./ctl.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10.16 <11 || >= 11.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "=2.0.0-alpha.0",
|
||||
"as-table": "^1.0.31",
|
||||
"chalk": "^3.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
"name": "@clusterio/ctl",
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusterioctl": "./dist/node/ctl.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"as-table": "^1.0.55",
|
||||
"fs-extra": "^11.1.1",
|
||||
"phin": "^3.7.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.10.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
|
||||
|
||||
/**
|
||||
* Base class for clusterioctl plugins
|
||||
*
|
||||
* Ctl plugins are subclasses of this class which get instantiated by
|
||||
* clusterioctl in order to extend its functionallity. To be discovered the
|
||||
* class must be exported under the name `CtlPlugin` in the module
|
||||
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
|
||||
*/
|
||||
export default class BaseCtlPlugin {
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
public info: PluginNodeEnvInfo,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called to add commands to the command line interface.
|
||||
*
|
||||
* Invoked by clusterioctl to let plugins add commands. `rootCommand` is
|
||||
* the top level command node which the plugin should add its own {@link
|
||||
* CommandTree} to.
|
||||
*
|
||||
* @param rootCommand -
|
||||
* Root of the clusterioctl command tree.
|
||||
*/
|
||||
async addCommands(rootCommand: CommandTree) { }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Clusterio Host
|
||||
|
||||
Node hosting Factorio servers in a Clusterio cluster.
|
||||
Clusterio hosts connect to the controller and waits for commands from the controller to start up and stop instances.
|
||||
A cluster can have any number of hosts in it located on different computers, and each host can host any number of instances each of which is a Factorio server that talks with the rest of the cluster.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
npx clusteriohost <command>
|
||||
|
||||
Common options:
|
||||
|
||||
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the host.
|
||||
Defaults to `plugin-list.json` and will be created if it does not exist.
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for the host.
|
||||
Defaults to `config-host.json` and will be created if it does not exist.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
Configure plugins available to be loaded by the host.
|
||||
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusteriohost plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
|
||||
Remove a plugin by its name.
|
||||
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
|
||||
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
|
||||
|
||||
For example, uninstalling the Subspace Storage plugin:
|
||||
|
||||
npx clusteriohost plugin remove subspace_storage
|
||||
npm uninstall @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin list`
|
||||
|
||||
Lists the plugins set up to be available by name followed by path.
|
||||
|
||||
|
||||
### `config`
|
||||
|
||||
Manage the host configuration offline.
|
||||
This should only be used when the host is stopped, otherwise the config read might be out of date and config changes will be overwritten when the host shuts down.
|
||||
|
||||
|
||||
#### `config set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
If value is not provided the entry is set to null.
|
||||
If the config entry is of type object the value must be a valid JSON serialization of an object.
|
||||
|
||||
See docs/configuration.md in the main repositiory for the available configuration.
|
||||
|
||||
|
||||
#### `config show <config-entry>`
|
||||
|
||||
Shows the value for a single config entry.
|
||||
|
||||
|
||||
#### `config list`
|
||||
|
||||
Lists up all configuration entries with their currently configured values.
|
||||
|
||||
|
||||
### `run`
|
||||
|
||||
Runs the host.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Clusterio host
|
||||
*
|
||||
* Connects to the controller and hosts Factorio servers that can
|
||||
* communicate with the cluster. It is remotely controlled by {@link
|
||||
* module:controller/controller}.
|
||||
*
|
||||
* @module host/host
|
||||
* @author Danielv123, Hornwitser
|
||||
* @example
|
||||
* npx clusteriohost run
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import yargs from "yargs";
|
||||
import setBlocking from "set-blocking";
|
||||
import { version } from "./package.json";
|
||||
import winston from "winston";
|
||||
import "winston-daily-rotate-file";
|
||||
|
||||
// internal libraries
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Host from "./src/Host";
|
||||
|
||||
let host: Host | undefined;
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_host_pending_requests",
|
||||
"Count of pending link requests currently waiting in memory on the host.",
|
||||
{
|
||||
labels: ["host_id"],
|
||||
callback: (gauge) => {
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
let count = host.pendingRequestCount;
|
||||
for (const connection of host.instanceConnections.values()) {
|
||||
count += connection.pendingRequestCount;
|
||||
count += connection.instance.pendingRequestCount;
|
||||
}
|
||||
gauge.labels(String(host.config.get("host.id"))).set(count);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export class HostConnector extends lib.WebSocketClientConnector {
|
||||
constructor(
|
||||
public hostConfig: lib.HostConfig,
|
||||
tlsCa: string | undefined,
|
||||
public pluginInfos: lib.PluginNodeEnvInfo[]
|
||||
) {
|
||||
super(
|
||||
hostConfig.get("host.controller_url"),
|
||||
hostConfig.get("host.max_reconnect_delay"),
|
||||
tlsCa
|
||||
);
|
||||
}
|
||||
|
||||
register() {
|
||||
logger.info("Connector | registering host");
|
||||
let plugins: Record<string, string> = {};
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
plugins[pluginInfo.name] = pluginInfo.version;
|
||||
}
|
||||
|
||||
this.sendHandshake(
|
||||
new lib.MessageRegisterHost(
|
||||
new lib.RegisterHostData(
|
||||
this.hostConfig.get("host.controller_token"),
|
||||
version,
|
||||
this.hostConfig.get("host.id"),
|
||||
plugins,
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function startHost() {
|
||||
// argument parsing
|
||||
// eslint-disable-next-line node/no-sync
|
||||
const args = yargs
|
||||
.scriptName("host")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stdout",
|
||||
default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)),
|
||||
type: "string",
|
||||
})
|
||||
.option("log-directory", {
|
||||
nargs: 1,
|
||||
describe: "Directory to place logs in",
|
||||
default: "logs",
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "host config file to use",
|
||||
default: "config-host.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("config", "Manage Host config", lib.configCommand)
|
||||
.command("run", "Run host", yargs => {
|
||||
yargs.option("can-restart", {
|
||||
type: "boolean", nargs: 0, default: false,
|
||||
describe: "Indicate that a process monitor will restart this host on failure",
|
||||
});
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.parseSync()
|
||||
;
|
||||
|
||||
logger.add(new winston.transports.DailyRotateFile({
|
||||
format: winston.format.json(),
|
||||
filename: "host-%DATE%.log",
|
||||
utc: true,
|
||||
dirname: path.join(args.logDirectory, "host"),
|
||||
}));
|
||||
if (args.logLevel !== "none") {
|
||||
logger.add(new ConsoleTransport({
|
||||
level: args.logLevel,
|
||||
format: new lib.TerminalFormat(),
|
||||
filter: (info: any) => info.instance_id === undefined,
|
||||
}));
|
||||
}
|
||||
lib.handleUnhandledErrors();
|
||||
|
||||
let command = args._[0];
|
||||
if (command === "run") {
|
||||
logger.info(`Starting Clusterio host ${version}`);
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (command === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
|
||||
logger.info(`Loading config from ${args.config}`);
|
||||
let hostConfig;
|
||||
try {
|
||||
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
|
||||
hostConfig = lib.HostConfig.fromJSON(jsonConfig, "host");
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info("Config not found, initializing new config");
|
||||
hostConfig = new lib.HostConfig("host");
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
await lib.handleConfigCommand(args, hostConfig, args.config);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get here the command was run
|
||||
|
||||
await fs.ensureDir(hostConfig.get("host.instances_directory"));
|
||||
await fs.ensureDir(hostConfig.get("host.mods_directory"));
|
||||
await fs.ensureDir("modules");
|
||||
|
||||
// Set the process title, shows up as the title of the CMD window on windows
|
||||
// and as the process name in ps/top on linux.
|
||||
process.title = "clusteriohost";
|
||||
|
||||
// make sure we have the controller access token
|
||||
if (hostConfig.get("host.controller_token") === "enter token here") {
|
||||
logger.fatal("ERROR invalid config!");
|
||||
logger.fatal(
|
||||
"Controller requires an access token for socket operations. As clusterio\n"+
|
||||
"hosts depends upon this, please set your token using the command npx\n"+
|
||||
"clusteriohost config set host.controller_token <token>. You can generate an\n"+
|
||||
"auth token using npx clusterioctl generate-host-token."
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure url ends with /
|
||||
if (!hostConfig.get("host.controller_url").endsWith("/")) {
|
||||
logger.fatal("ERROR invalid config!");
|
||||
logger.fatal("host.controller_url must end with '/'");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let tlsCa: string | undefined;
|
||||
let tlsCaPath = hostConfig.get("host.tls_ca");
|
||||
if (tlsCaPath) {
|
||||
tlsCa = await fs.readFile(tlsCaPath, "utf8");
|
||||
}
|
||||
|
||||
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
|
||||
host = new Host(
|
||||
hostConnector,
|
||||
args.config,
|
||||
hostConfig,
|
||||
tlsCa,
|
||||
pluginInfos,
|
||||
Boolean(args.canRestart),
|
||||
...await Host.bootstrap(hostConfig)
|
||||
);
|
||||
|
||||
// Handle interrupts
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
|
||||
try {
|
||||
await host.loadPlugins();
|
||||
} catch (err) {
|
||||
await host.shutdown();
|
||||
throw err;
|
||||
}
|
||||
|
||||
hostConnector.once("connect", () => {
|
||||
logger.add(new lib.LinkTransport({ link: host! }));
|
||||
});
|
||||
|
||||
await hostConnector.connect();
|
||||
logger.info("Started host");
|
||||
}
|
||||
|
||||
export function bootstrap() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startHost().catch(err => {
|
||||
if (err instanceof lib.AuthenticationFailed) {
|
||||
logger.fatal(err.message);
|
||||
|
||||
} else if (err instanceof lib.StartupError) {
|
||||
logger.fatal(`
|
||||
+----------------------------------+
|
||||
| Unable to to start clusteriohost |
|
||||
+----------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
|
||||
} else if (err instanceof lib.PluginError) {
|
||||
logger.fatal(`
|
||||
${err.pluginName} plugin threw an unexpected error
|
||||
during startup, please report it to the plugin author.
|
||||
------------------------------------------------------
|
||||
${err.original.stack}`
|
||||
);
|
||||
|
||||
} else {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured while starting host, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
if (err instanceof lib.AuthenticationFailed) {
|
||||
process.exitCode = 8;
|
||||
} else {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { bootstrap } from "./host";
|
||||
export { default as BaseHostPlugin } from "./src/BaseHostPlugin";
|
||||
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
|
||||
export { default as Host, HostRouter } from "./src/Host";
|
||||
export { default as Instance } from "./src/Instance";
|
||||
export { default as InstanceConnection } from "./src/InstanceConnection";
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Important: Keep this API in sync with modules/clusterio/api.lua
|
||||
local api = {}
|
||||
|
||||
|
||||
local initialized = false
|
||||
function api.init()
|
||||
if initialized then
|
||||
return
|
||||
end
|
||||
initialized = true
|
||||
|
||||
if remote.interfaces.clusterio_api then
|
||||
api.events = remote.call("clusterio_api", "get_events")
|
||||
else
|
||||
local null_event = script.generate_event_name()
|
||||
api.events = {
|
||||
on_instance_updated = null_event,
|
||||
on_server_startup = null_event,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local function call_api(fn, ...)
|
||||
if remote.interfaces.clusterio_api and remote.interfaces.clusterio_api[fn] then
|
||||
return remote.call("clusterio_api", fn, ...)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
function api.get_instance_name()
|
||||
return call_api("get_instance_name")
|
||||
end
|
||||
|
||||
function api.get_instance_id()
|
||||
return call_api("get_instance_id")
|
||||
end
|
||||
|
||||
|
||||
function api.send_json(channel, data)
|
||||
if not remote.interfaces.clusterio_api then
|
||||
return
|
||||
end
|
||||
|
||||
-- Escape bad characters. The question mark is used for separating the
|
||||
-- channel name from the payload.
|
||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return "\\x" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
|
||||
data = game.table_to_json(data)
|
||||
|
||||
-- If there's more than about 4kB of data users running with the Windows
|
||||
-- console open will start to experience stuttering, otherwise we could
|
||||
-- output about 1 MB of data at a time through stdout.
|
||||
if #data < 4000 then
|
||||
print("\f$ipc:" .. channel .. "?j" .. data)
|
||||
else
|
||||
local file_no = remote.call("clusterio_api", "get_file_no")
|
||||
local file_name = "clst_" .. file_no .. ".json"
|
||||
game.write_file(file_name, data, false, 0)
|
||||
print("\f$ipc:" .. channel .. "?f" .. file_name)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return api
|
||||
@@ -1,4 +1,8 @@
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Version: 0.1.2
|
||||
Features:
|
||||
- Added support for Factorio 1.1.
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Version: 0.1.1
|
||||
Features:
|
||||
- Added support for Factorio 0.18.x
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "clusterio_lib",
|
||||
"variants": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"factorio_version": "0.17"
|
||||
},
|
||||
{
|
||||
"version": "0.1.1",
|
||||
"factorio_version": "0.18"
|
||||
},
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"factorio_version": "1.1"
|
||||
}
|
||||
],
|
||||
"title": "Clusterio Library (Alpha)",
|
||||
"author": "Clusterio Team",
|
||||
"homepage": "https://github.com/clusterio/clusterio",
|
||||
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
|
||||
"additional_files": {
|
||||
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
local function is_array(t)
|
||||
local count = 0
|
||||
for k in pairs(t) do
|
||||
if type(k) ~= "number" then
|
||||
return false
|
||||
end
|
||||
count = count + 1
|
||||
end
|
||||
|
||||
for i = 1, count do
|
||||
if not t[i] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return count ~= 0
|
||||
end
|
||||
|
||||
-- no game.table_to_json here :(
|
||||
local function table_to_json(data)
|
||||
if type(data) == "number" then
|
||||
if data == math.huge then return "1e500" end -- Inf is not representable in JSON, but 1e500 will overflow to it.
|
||||
if data == -math.huge then return "-1e500" end
|
||||
if data ~= data then return "0" end -- NaN is not representable in JSON, treat as 0 instead.
|
||||
return string.format("%.17g", data)
|
||||
|
||||
elseif type(data) == "string" then
|
||||
data = data:gsub('([\x00-\x1f\\"])', function(match)
|
||||
return "\\u00" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
return '"' .. data .. '"'
|
||||
|
||||
elseif data == nil then
|
||||
return "null"
|
||||
|
||||
elseif data == true then
|
||||
return "true"
|
||||
elseif data == false then
|
||||
return "false"
|
||||
|
||||
elseif type(data) == "table" then
|
||||
local r
|
||||
if is_array(data) then
|
||||
r = {"["}
|
||||
for i=1, #data do
|
||||
r[#r+1] = table_to_json(data[i])
|
||||
r[#r+1] = ","
|
||||
end
|
||||
|
||||
-- overwrite last comma, empty table is an object
|
||||
r[#r] = "]"
|
||||
|
||||
else
|
||||
r = {"{"}
|
||||
for k, v in pairs(data) do
|
||||
r[#r+1] = table_to_json(tostring(k))
|
||||
r[#r+1] = ":"
|
||||
r[#r+1] = table_to_json(v)
|
||||
r[#r+1] = ","
|
||||
end
|
||||
r[#r + (r[#r] == "," and 0 or 1)] = "}"
|
||||
end
|
||||
|
||||
return table.concat(r)
|
||||
end
|
||||
end
|
||||
|
||||
local clusterio_api = {}
|
||||
function clusterio_api.send_json(channel, data)
|
||||
data = table_to_json(data)
|
||||
print("\f$ipc:" .. channel .. "?j" .. data)
|
||||
end
|
||||
return clusterio_api
|
||||
@@ -0,0 +1,11 @@
|
||||
local function send_json(channel, data)
|
||||
data = game.table_to_json(data)
|
||||
print("\f$ipc:" .. channel .. "?j" .. data)
|
||||
end
|
||||
|
||||
script.on_init(function()
|
||||
send_json("mod_list", game.active_mods)
|
||||
for name, mod_setting in pairs(game.mod_setting_prototypes) do
|
||||
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,8 @@
|
||||
local clusterio_api = require("clusterio_api")
|
||||
|
||||
log("Exporting prototypes")
|
||||
for group, prototypes in pairs(data.raw) do
|
||||
for name, prototype in pairs(prototypes) do
|
||||
clusterio_api.send_json("prototype_export", prototype)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "export",
|
||||
"version": "0.0.0",
|
||||
"title": "Icon exporter",
|
||||
"author": "Clusterio Team",
|
||||
"homepage": "https://github.com/clusterio/clusterio",
|
||||
"description": "Exports item prototypes on stdout during the data stage."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
local clusterio_api = require("clusterio_api")
|
||||
|
||||
log("Exporting settings")
|
||||
for group, prototypes in pairs(data.raw) do
|
||||
for name, prototype in pairs(prototypes) do
|
||||
clusterio_api.send_json("settings_export", prototype)
|
||||
end
|
||||
end
|
||||